diff --git a/Cargo.lock b/Cargo.lock index 9d2aed860..997b88146 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1852,6 +1852,7 @@ dependencies = [ "tracing", "tracing-subscriber", "tz-rs", + "urlencoding", "which 8.0.0", "winres", "zeroize", @@ -8237,6 +8238,12 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "usvg" version = "0.46.0" diff --git a/Cargo.toml b/Cargo.toml index 3f1426a1e..5756de6f5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -74,6 +74,7 @@ which = { version = "8.0.0" } tz-rs = { version = "0.7.0" } tempfile = "3.20.0" arc-swap = "1" +urlencoding = "2" [target.'cfg(not(target_os = "windows"))'.dependencies] zmq = "0.10.0" diff --git a/docs/ai-design/2026-03-04-multi-wallet-rpc/manual-test-scenarios.md b/docs/ai-design/2026-03-04-multi-wallet-rpc/manual-test-scenarios.md new file mode 100644 index 000000000..1ca28e4bb --- /dev/null +++ b/docs/ai-design/2026-03-04-multi-wallet-rpc/manual-test-scenarios.md @@ -0,0 +1,736 @@ +# Manual Test Scenarios: Multi-Wallet RPC Support + +## Overview + +When Dash Core (dash-qt) has multiple wallets loaded, wallet-specific RPC calls fail with error code -19 ("Wallet file not specified"). This feature handles Core wallet association at two levels: + +1. **Wallet creation and import (inline selection):** When creating or importing a DET wallet, if Dash Core has more than one wallet loaded, an inline ComboBox labeled "Dash Core Wallet" appears in the form UI before the Save/Import button. The user selects the desired Core wallet before saving. The `core_wallet_name` is stored per-wallet in SQLite. If Dash Core has 0 or 1 wallets loaded, the ComboBox is not shown and `core_wallet_name` is set to `None`. + +2. **Runtime recovery on Wallets screen (auto-detection then modal dialog):** For legacy wallets created before the inline selection feature (with `NULL` `core_wallet_name` in the database), an RPC error -19 at runtime triggers a recovery flow **only on the Wallets screen**: + - **Auto-detection step:** The Wallets screen calls `getaddressinfo` on each loaded Core wallet for the DET wallet's first address. If exactly one Core wallet reports `is_mine=true` or `is_watchonly=true` for that address, the wallet is auto-assigned without showing any dialog. + - **Modal dialog fallback:** If 0 or more than 1 Core wallets match (ambiguous or no match), the `SelectionDialog` modal is shown and the user selects manually. If only one Core wallet is loaded at all, it is auto-selected without showing a dialog and without address checking. + - The selected association is persisted to SQLite. + +3. **Error banner on non-Wallets screens:** When error -19 occurs on any screen other than the Wallets screen (e.g., Send, Identities, Tokens), no dialog is shown. Instead, an error banner is displayed instructing the user to go to the Wallets screen and refresh to resolve the issue. + +4. **Per-wallet storage:** Each DET wallet (HD or single-key) stores its own `core_wallet_name` in SQLite (`wallet` and `single_key_wallet` tables). Different DET wallets can be associated with different Core wallets. The `/wallet/` URL path is used for all RPC calls for that wallet. + +Key components: +- **Inline ComboBox** (`add_new_wallet_screen.rs`, `import_mnemonic_screen.rs`) -- appears in the creation/import form when Dash Core has >1 wallets loaded +- **SelectionDialog** (`src/ui/components/selection_dialog.rs`) -- modal dialog used only on the **Wallets screen** for runtime recovery of legacy wallets hitting error -19 +- **Wallets screen** (`src/ui/wallets/`) -- handles `CoreWalletNotConfigured` result for runtime recovery; runs auto-detection before falling back to modal +- **Non-Wallets screens** -- display error banner on error -19, directing the user to the Wallets screen +- **SQLite persistence** (`src/database/wallet.rs`, `src/database/single_key_wallet.rs`) -- `core_wallet_name` column per wallet +- **`core_client_for_wallet()`** (`src/context/mod.rs`) -- builds RPC client with `/wallet/` path +- **`check_wallet_not_specified()`** (`src/backend_task/core/mod.rs`) -- detects error -19 and triggers wallet selection + +--- + +## MWT-001: Single Core wallet -- auto-select on first RPC call + +| Field | Value | +|---|---| +| **ID** | MWT-001 | +| **Title** | Single Core wallet is auto-selected transparently | +| **Priority** | P0 | + +### Prerequisites +- Dash Core running with exactly **one** wallet loaded +- A DET wallet with `core_wallet_name = NULL` in SQLite (legacy wallet or newly created when only 1 Core wallet existed) +- Application configured for any network with RPC backend + +### Steps +1. Launch the application +2. Navigate to the Wallets screen +3. Trigger any wallet-related RPC operation (e.g., refresh balance) + +### Expected Result +- The RPC call initially triggers error -19, but the application handles it transparently +- No SelectionDialog modal is shown to the user +- Because exactly one Core wallet is loaded, the app skips address checking and auto-selects the sole wallet directly (no `getaddressinfo` calls are made) +- The original operation completes successfully +- `core_wallet_name` is persisted to SQLite in the `wallet` or `single_key_wallet` table for this specific wallet +- All subsequent RPC calls for this wallet use `/wallet/` URL path without further prompts + +--- + +## MWT-002: Multiple Core wallets -- auto-detection then recovery via modal dialog on Wallets screen + +| Field | Value | +|---|---| +| **ID** | MWT-002 | +| **Title** | SelectionDialog modal appears on Wallets screen when address matching is ambiguous | +| **Priority** | P0 | + +### Prerequisites +- Dash Core running with **two or more** wallets loaded (e.g., `wallet.dat`, `savings`, `trading`) +- A DET wallet with `core_wallet_name = NULL` in SQLite (legacy wallet) +- The DET wallet's first address is either not imported in any Core wallet, or is present in more than one Core wallet +- The user is on the **Wallets screen** + +### Steps +1. Launch the application +2. Navigate to the **Wallets screen** +3. Trigger any wallet-related RPC operation for the legacy wallet (e.g., refresh balance) +4. Observe that the application first attempts address-based auto-detection (calls `getaddressinfo` on each loaded Core wallet for the DET wallet's first address) +5. Because the address match is ambiguous (0 or >1 Core wallets match), observe the SelectionDialog modal that appears +6. Open the ComboBox dropdown and review the listed wallet names +7. Select a wallet (e.g., `savings`) +8. Click the "Confirm" button + +### Expected Result +- Step 4: The app queries each Core wallet via `getaddressinfo`; no unique match is found +- Step 5: A modal overlay appears with a SelectionDialog titled "Select Dash Core Wallet" +- Step 6: The ComboBox lists all wallet names returned by `listwallets` +- Step 8: The dialog closes; the original RPC operation retries and succeeds +- `core_wallet_name=savings` is persisted to SQLite for this specific DET wallet +- All subsequent RPC calls for this wallet use `/wallet/savings` +- The selection persists across application restarts + +--- + +## MWT-003: Multiple Core wallets -- user cancels recovery dialog on Wallets screen + +| Field | Value | +|---|---| +| **ID** | MWT-003 | +| **Title** | Canceling the SelectionDialog on Wallets screen shows info banner | +| **Priority** | P0 | + +### Prerequisites +- Dash Core running with two or more wallets loaded +- A DET wallet with `core_wallet_name = NULL` in SQLite +- The user is on the **Wallets screen** + +### Steps +1. Trigger a wallet-related RPC operation for the legacy wallet +2. Observe the SelectionDialog modal +3. Click the "Cancel" button + +### Expected Result +- The dialog closes +- An info banner appears: "Dash Core wallet not selected. Some operations may fail until a wallet is assigned." +- The original RPC operation fails gracefully -- no crash or hang +- No `core_wallet_name` is persisted to SQLite +- Subsequent RPC operations for this wallet re-trigger error -19 and re-prompt the dialog (only while on the Wallets screen) + +--- + +## MWT-004: DET wallet creation with single Core wallet + +| Field | Value | +|---|---| +| **ID** | MWT-004 | +| **Title** | Creating a new DET wallet with single Core wallet works seamlessly | +| **Priority** | P0 | + +### Prerequisites +- Dash Core running with exactly one wallet loaded (or no wallets loaded) +- No existing DET wallets in the application + +### Steps +1. Navigate to Wallets screen and click "Create Wallet" +2. Complete the entropy grid, generate seed phrase, write it down +3. Enter a wallet name (e.g., "My Test Wallet") +4. Observe the form -- verify no "Dash Core Wallet" ComboBox is visible +5. Click "Save Wallet" +6. Observe the wallet creation success screen + +### Expected Result +- Step 4: No inline Core wallet ComboBox is shown because Dash Core has 0 or 1 wallets loaded +- Step 5: The wallet is created successfully +- Step 6: The success screen shows "Wallet Created Successfully!" with next steps +- The wallet is saved to SQLite with `core_wallet_name = NULL` +- When a subsequent RPC call occurs and Dash Core has one wallet, it is auto-selected (MWT-001 behavior) + +--- + +## MWT-005: DET wallet creation with multiple Core wallets + +| Field | Value | +|---|---| +| **ID** | MWT-005 | +| **Title** | Inline ComboBox appears in creation form when multiple Core wallets exist | +| **Priority** | P0 | + +### Prerequisites +- Dash Core running with two or more wallets loaded (e.g., `wallet.dat`, `savings`) +- No existing DET wallets in the application + +### Steps +1. Navigate to Wallets screen and click "Create Wallet" +2. Complete the wallet creation form (entropy, seed phrase, name, password) +3. Observe the form UI before the "Save Wallet" button -- a ComboBox labeled "Dash Core Wallet" should be visible +4. Open the ComboBox dropdown and verify all Core wallet names are listed +5. Select the desired Core wallet (e.g., `savings`) +6. Click "Save Wallet" + +### Expected Result +- Step 3: An inline ComboBox labeled "Dash Core Wallet" is visible in the form, before the Save button +- Step 4: The dropdown lists all wallet names returned by Dash Core's `listwallets` RPC +- Step 6: The wallet is created with `core_wallet_name=savings` already set in SQLite -- no post-creation modal dialog appears +- All subsequent RPC calls for this wallet use `/wallet/savings` +- The association persists across application restarts + +--- + +## MWT-006: DET wallet import with multiple Core wallets + +| Field | Value | +|---|---| +| **ID** | MWT-006 | +| **Title** | Inline ComboBox appears in import form when multiple Core wallets exist | +| **Priority** | P1 | + +### Prerequisites +- Dash Core running with multiple wallets loaded +- A valid BIP39 seed phrase available for import + +### Steps +1. Navigate to Wallets screen and click "Import Wallet" +2. Enter the seed phrase and complete other import fields +3. Observe the form UI before the "Save" / "Import" button -- a ComboBox labeled "Dash Core Wallet" should be visible +4. Open the ComboBox dropdown and verify all Core wallet names are listed +5. Select the appropriate Core wallet +6. Click Save / Import + +### Expected Result +- Step 3: An inline ComboBox labeled "Dash Core Wallet" is visible in the import form, before the Save button +- Step 4: The dropdown lists all Core wallets +- Step 6: The wallet is imported with `core_wallet_name` already set in SQLite -- no post-import modal dialog +- The imported wallet's balance and transactions are fetched correctly via the selected Core wallet +- The association persists across application restarts + +--- + +## MWT-007: Error -19 runtime recovery re-prompts selection on Wallets screen + +| Field | Value | +|---|---| +| **ID** | MWT-007 | +| **Title** | Re-prompts SelectionDialog on Wallets screen when configured Core wallet is no longer available | +| **Priority** | P0 | + +### Prerequisites +- A DET wallet with `core_wallet_name=old_wallet` in SQLite +- Dash Core initially had `old_wallet` loaded but it was unloaded mid-session +- At least one other wallet is now loaded in Core +- The user is on the **Wallets screen** + +### Steps +1. Confirm the application was working with `old_wallet` (RPC calls succeeding) +2. Unload `old_wallet` from Dash Core (via `dash-cli unloadwallet old_wallet`) +3. Trigger a wallet-related RPC operation in the application for that DET wallet (e.g., refresh) + +### Expected Result +- The RPC call to `/wallet/old_wallet` fails +- The Wallets screen detects the failure and re-triggers the wallet detection flow +- If exactly one wallet remains loaded, it auto-selects (MWT-001 behavior) +- If multiple wallets remain, auto-detection via `getaddressinfo` is attempted first; if a unique match is found it auto-assigns; otherwise the SelectionDialog modal appears on the Wallets screen (MWT-002 behavior) +- SQLite is updated with the newly selected `core_wallet_name`, replacing `old_wallet` +- Subsequent RPC calls for this wallet succeed with the new wallet + +--- + +## MWT-008: Pre-configured Core wallet name in SQLite + +| Field | Value | +|---|---| +| **ID** | MWT-008 | +| **Title** | Application uses per-wallet Core wallet name from SQLite without prompting | +| **Priority** | P1 | + +### Prerequisites +- A DET wallet in SQLite with `core_wallet_name=savings` +- Dash Core running with the `savings` wallet loaded + +### Steps +1. Launch the application +2. Navigate to the Wallets screen +3. Select the wallet that has `core_wallet_name=savings` +4. Trigger a wallet-related RPC operation + +### Expected Result +- The RPC call uses `/wallet/savings` URL path from the start +- No error -19 occurs +- No SelectionDialog modal is shown and no inline ComboBox prompt appears +- The operation completes successfully on the first attempt + +--- + +## MWT-009: SelectionDialog UI interactions on Wallets screen (Escape, X button, Enter) + +| Field | Value | +|---|---| +| **ID** | MWT-009 | +| **Title** | SelectionDialog on Wallets screen supports keyboard shortcuts and X close button | +| **Priority** | P1 | + +### Prerequisites +- Dash Core running with two or more wallets loaded +- A legacy DET wallet with `core_wallet_name = NULL` in SQLite +- The user is on the **Wallets screen** +- A wallet-related RPC operation triggers the SelectionDialog + +**Note:** This scenario applies only to the runtime recovery modal on the Wallets screen, not to the inline ComboBox in wallet creation/import screens. + +### Steps +1. Observe the dialog overlay -- verify it dims/blocks the background UI +2. Observe dialog contents: title, instruction message, ComboBox, Confirm/Cancel buttons +3. Press the **Escape** key +4. Re-trigger the dialog (perform another RPC operation) +5. Click the **X** (close) button on the dialog window +6. Re-trigger the dialog again +7. Use the ComboBox to select a wallet, then press **Enter** + +### Expected Result +- Step 1: A semi-transparent overlay covers the main UI; only the dialog is interactive +- Step 2: Dialog shows "Select Dash Core Wallet" title, instruction text, ComboBox with wallet names, Confirm and Cancel buttons +- Step 3: Escape closes the dialog (equivalent to Cancel) -- info banner appears (MWT-003 behavior) +- Step 5: X button closes the dialog (equivalent to Cancel) -- info banner appears +- Step 7: Enter key confirms the current ComboBox selection -- wallet is selected and persisted to SQLite for this wallet + +--- + +## MWT-010: Network switch -- independent wallet selection per network + +| Field | Value | +|---|---| +| **ID** | MWT-010 | +| **Title** | Each DET wallet on each network maintains its own Core wallet name in SQLite | +| **Priority** | P1 | + +### Prerequisites +- Application configured with both Testnet and Mainnet RPC backends +- Testnet Core has wallets: `testnet_wallet_1`, `testnet_wallet_2` +- Mainnet Core has wallets: `mainnet_wallet_A`, `mainnet_wallet_B` +- DET wallets exist on both networks with `core_wallet_name = NULL` + +### Steps +1. Connect to Testnet +2. Create a new DET wallet -- observe the inline ComboBox, select `testnet_wallet_1`, save +3. Switch to Mainnet +4. Create a new DET wallet -- observe the inline ComboBox, select `mainnet_wallet_A`, save +5. Switch back to Testnet +6. Trigger a wallet-related RPC operation for the Testnet wallet + +### Expected Result +- Step 2: Testnet wallet is saved with `core_wallet_name=testnet_wallet_1` in SQLite +- Step 4: A new inline ComboBox appears listing Mainnet wallets (not Testnet wallets); Mainnet wallet is saved with `core_wallet_name=mainnet_wallet_A` +- Step 5-6: No dialog or ComboBox appears; Testnet wallet uses `testnet_wallet_1` from SQLite +- Each DET wallet's `core_wallet_name` is stored independently in its own row in the `wallet` table + +--- + +## MWT-011: Multiple DET wallets with different Core wallet associations + +| Field | Value | +|---|---| +| **ID** | MWT-011 | +| **Title** | Different DET wallets can be associated with different Core wallets | +| **Priority** | P1 | + +### Prerequisites +- Dash Core running with multiple wallets loaded (e.g., `personal`, `business`) +- Two or more DET wallets in the application + +### Steps +1. Create DET Wallet A -- in the inline ComboBox, select `personal`, save +2. Create DET Wallet B -- in the inline ComboBox, select `business`, save +3. Select DET Wallet A in the UI and trigger an RPC operation (e.g., refresh balance) +4. Select DET Wallet B in the UI and trigger an RPC operation + +### Expected Result +- Step 3: RPC calls for Wallet A use `/wallet/personal` +- Step 4: RPC calls for Wallet B use `/wallet/business` +- Each DET wallet has its own `core_wallet_name` stored in SQLite (per-wallet, not global) +- No SelectionDialog appears for either wallet +- Both wallets' balances and transactions are fetched correctly via their respective Core wallet endpoints + +--- + +## MWT-012: Wallet name with special characters + +| Field | Value | +|---|---| +| **ID** | MWT-012 | +| **Title** | Core wallet names with spaces, dots, and special characters are handled correctly | +| **Priority** | P2 | + +### Prerequisites +- Dash Core running with wallets having special characters in names: + - `my wallet.dat` (space and dot) + - `test-wallet_2` (hyphen and underscore) + - `wallet (backup)` (parentheses and space) + +### Steps +1. Create a new DET wallet +2. Observe the inline ComboBox listing the Core wallets +3. Select `my wallet.dat` +4. Click "Save Wallet" +5. Verify the RPC call succeeds (e.g., balance refresh) +6. Restart the application +7. Trigger another wallet-related RPC operation for this wallet + +### Expected Result +- Step 2: All wallet names including those with special characters are displayed correctly in the ComboBox +- Step 4: Wallet is saved with `core_wallet_name=my wallet.dat` in SQLite +- Step 5: The RPC URL path handles the wallet name correctly (e.g., `/wallet/my wallet.dat`) +- Step 7: After restart, the persisted name is read from SQLite correctly and used without re-prompting +- Names containing `/` or `..` are rejected by `core_client_for_wallet()` as invalid + +--- + +## MWT-013: Dash Core not running -- no false wallet selection trigger + +| Field | Value | +|---|---| +| **ID** | MWT-013 | +| **Title** | Connection errors do not trigger the wallet selection flow | +| **Priority** | P2 | + +### Prerequisites +- Dash Core is **not running** or RPC endpoint is unreachable +- A DET wallet with `core_wallet_name = NULL` in SQLite + +### Steps +1. Launch the application +2. Attempt a wallet-related RPC operation + +### Expected Result +- A normal connection error is displayed (e.g., "Could not connect to Dash Core") +- The SelectionDialog is **not** triggered -- the wallet selection flow only activates on error code -19 ("Wallet file not specified") +- No `core_wallet_name` is persisted to SQLite + +--- + +## MWT-014: Concurrent RPC calls during wallet detection on Wallets screen + +| Field | Value | +|---|---| +| **ID** | MWT-014 | +| **Title** | Only one SelectionDialog appears even with concurrent error -19 triggers | +| **Priority** | P2 | + +### Prerequisites +- Dash Core running with multiple wallets loaded +- A legacy DET wallet with `core_wallet_name = NULL` in SQLite +- Multiple background tasks may trigger RPC calls simultaneously +- The user is on the **Wallets screen** + +### Steps +1. Trigger multiple wallet-related operations in quick succession (e.g., navigate to wallet screen which triggers refresh + balance check concurrently) + +### Expected Result +- Only **one** SelectionDialog modal appears (not multiple stacked dialogs) +- The first error -19 triggers the detection; subsequent errors are handled after selection +- After selection, all pending and subsequent operations succeed +- No race condition causes duplicate SQLite writes or conflicting wallet names + +--- + +## MWT-015: Legacy wallet without core_wallet_name triggers runtime modal on Wallets screen + +| Field | Value | +|---|---| +| **ID** | MWT-015 | +| **Title** | Legacy wallet with NULL core_wallet_name triggers auto-detection then recovery modal on Wallets screen | +| **Priority** | P1 | + +### Prerequisites +- A DET wallet created before the inline selection feature was added (has `core_wallet_name = NULL` in SQLite `wallet` or `single_key_wallet` table) +- Dash Core running with **two or more** wallets loaded +- The legacy wallet has never had a Core wallet associated +- The DET wallet's first address is either absent from all Core wallets or present in more than one (auto-detection is ambiguous) +- The user is on the **Wallets screen** + +### Steps +1. Launch the application +2. Navigate to the **Wallets screen** +3. Select the legacy DET wallet in the UI +4. Trigger an RPC operation that requires Core interaction (e.g., refresh balance) +5. Observe that the app first runs auto-detection: calls `getaddressinfo` on each loaded Core wallet for the DET wallet's first address +6. Because the match is ambiguous, observe the SelectionDialog modal that appears (rendered by the Wallets screen) +7. Select the desired Core wallet from the ComboBox dropdown +8. Click "Confirm" +9. Verify the original operation completes or can be retried successfully +10. Restart the application +11. Trigger another RPC operation for the same wallet + +### Expected Result +- Step 4: The RPC call fails with error -19 ("Wallet file not specified") +- Step 5: The Wallets screen queries each Core wallet via `getaddressinfo`; no unique match is identified +- Step 6: The Wallets screen receives `CoreWalletSelectionNeeded` result and shows a SelectionDialog modal with all loaded Core wallets +- Step 7: The ComboBox lists wallet names from `listwallets` RPC +- Step 8: The dialog closes; `core_wallet_name` is persisted to SQLite for this specific wallet; a success banner appears ("Dash Core wallet '' assigned") +- Step 9: The screen refreshes and subsequent RPC operations succeed using `/wallet/` +- Step 10-11: After restart, the wallet's `core_wallet_name` is loaded from SQLite and used automatically -- no modal appears + +### Edge Cases +- If the legacy wallet is a single-key wallet, verify `single_key_wallet.core_wallet_name` is updated in SQLite +- If the user cancels the modal, the info banner appears and no association is stored; the next RPC operation re-triggers error -19 and re-prompts the dialog (only on Wallets screen) +- If Dash Core is restarted and only 1 wallet remains, auto-selection occurs without showing the modal +- If auto-detection finds a unique match, the dialog is skipped entirely (see MWT-016) + +--- + +## MWT-016: Auto-detection success -- address found in exactly one Core wallet + +| Field | Value | +|---|---| +| **ID** | MWT-016 | +| **Title** | Auto-detection assigns Core wallet silently when address matches exactly one Core wallet | +| **Priority** | P0 | + +### Prerequisites +- Dash Core running with **two or more** wallets loaded (e.g., `wallet.dat`, `savings`, `trading`) +- A DET wallet with `core_wallet_name = NULL` in SQLite (legacy wallet) +- The DET wallet's first address has been imported into exactly **one** of the loaded Core wallets (e.g., `savings`) -- `getaddressinfo` for that address returns `is_mine=true` or `is_watchonly=true` in `savings` only, and returns `is_mine=false`, `is_watchonly=false` in all others +- The user is on the **Wallets screen** + +### Steps +1. Launch the application +2. Navigate to the **Wallets screen** +3. Select the legacy DET wallet in the UI +4. Trigger any wallet-related RPC operation (e.g., refresh balance) +5. Observe the application behavior -- no SelectionDialog should appear + +### Expected Result +- Step 4: The RPC call fails with error -19 +- The Wallets screen calls `getaddressinfo` on each loaded Core wallet for the DET wallet's first address +- Exactly one Core wallet (`savings`) reports `is_mine=true` or `is_watchonly=true` +- The app auto-assigns `core_wallet_name=savings` without showing any dialog +- A success banner appears (e.g., "Dash Core wallet 'savings' assigned automatically") +- The original RPC operation retries and completes successfully using `/wallet/savings` +- `core_wallet_name=savings` is persisted to SQLite for this specific DET wallet +- All subsequent RPC calls for this wallet use `/wallet/savings` without further prompts +- The association persists across application restarts + +--- + +## MWT-017: Auto-detection ambiguous -- address in zero or multiple Core wallets falls back to dialog on Wallets screen + +| Field | Value | +|---|---| +| **ID** | MWT-017 | +| **Title** | Auto-detection falls back to SelectionDialog on Wallets screen when address matches 0 or >1 Core wallets | +| **Priority** | P1 | + +### Prerequisites +- Dash Core running with **two or more** wallets loaded +- A DET wallet with `core_wallet_name = NULL` in SQLite (legacy wallet) +- The user is on the **Wallets screen** + +This scenario covers two sub-cases: + +**Sub-case A -- address in more than one Core wallet:** +- The DET wallet's first address has been imported into two or more of the loaded Core wallets (e.g., both `wallet.dat` and `savings` report `is_mine=true` or `is_watchonly=true`) + +**Sub-case B -- address in zero Core wallets:** +- The DET wallet's first address is not imported in any loaded Core wallet (all return `is_mine=false`, `is_watchonly=false`) + +### Steps (Sub-case A) +1. Ensure the DET wallet's first address is present in two or more loaded Core wallets +2. Navigate to the **Wallets screen** +3. Trigger a wallet-related RPC operation for the legacy DET wallet +4. Observe that auto-detection finds multiple matches and falls back to the SelectionDialog modal +5. Select a Core wallet from the ComboBox and click "Confirm" + +### Steps (Sub-case B) +1. Ensure the DET wallet's first address is not imported in any loaded Core wallet +2. Navigate to the **Wallets screen** +3. Trigger a wallet-related RPC operation for the legacy DET wallet +4. Observe that auto-detection finds no matches and falls back to the SelectionDialog modal +5. Select a Core wallet from the ComboBox and click "Confirm" + +### Expected Result (both sub-cases) +- Auto-detection runs `getaddressinfo` on all loaded Core wallets for the DET wallet's first address +- Because the result is ambiguous (0 or >1 matches), no automatic assignment is made +- The SelectionDialog modal appears on the Wallets screen listing all loaded Core wallet names +- After the user confirms, `core_wallet_name` is persisted to SQLite and the original operation retries successfully +- No automatic assignment banner appears; the standard selection confirmation banner is shown instead + +--- + +## MWT-018: Fresh wallet auto-detection -- address not yet imported in any Core wallet + +| Field | Value | +|---|---| +| **ID** | MWT-018 | +| **Title** | Freshly created DET wallet with no Core wallet import falls back to dialog on Wallets screen | +| **Priority** | P1 | + +### Prerequisites +- Dash Core running with **two or more** wallets loaded +- A DET wallet was created when Dash Core had only one wallet (so `core_wallet_name = NULL`), but since then additional Core wallets have been loaded +- The DET wallet's first address has **never** been imported into any of the currently loaded Core wallets (the wallet's keys exist only in DET, not imported into Dash Core) +- The user is on the **Wallets screen** + +### Steps +1. Launch the application with the above wallet state +2. Navigate to the **Wallets screen** +3. Select the DET wallet in the UI +4. Trigger any wallet-related RPC operation (e.g., refresh balance) +5. Observe that auto-detection runs but finds no match in any Core wallet +6. Observe the SelectionDialog modal appears +7. Select the appropriate Core wallet (the one where the DET wallet keys are or will be used) +8. Click "Confirm" + +### Expected Result +- Step 4: The RPC call fails with error -19 +- Step 5: `getaddressinfo` is called for the DET wallet's first address on each loaded Core wallet; all return `is_mine=false`, `is_watchonly=false` +- Step 6: Auto-detection yields no unique match; the SelectionDialog modal appears on the Wallets screen with all loaded Core wallet names +- Step 7-8: The user manually assigns the correct Core wallet; `core_wallet_name` is persisted to SQLite +- The original operation retries and succeeds using the user-selected Core wallet +- Subsequent RPC calls use the persisted `core_wallet_name` without re-prompting + +### Edge Cases +- If the user later imports the DET wallet address into a Core wallet and the `core_wallet_name` is later cleared (set back to NULL), the next error -19 will again run auto-detection -- this time finding the imported address and auto-assigning (MWT-016 behavior) +- This scenario is distinct from MWT-016 (unique match) and MWT-017 Sub-case B (same flow, but that scenario starts with Core wallets that previously held the address) + +--- + +## MWT-019: Error -19 on non-Wallets screen shows error banner (no dialog) + +| Field | Value | +|---|---| +| **ID** | MWT-019 | +| **Title** | Error -19 on non-Wallets screen displays error banner instead of dialog | +| **Priority** | P0 | + +### Prerequisites +- Dash Core running with **two or more** wallets loaded +- A DET wallet with `core_wallet_name = NULL` in SQLite (legacy wallet) +- The user is on any screen **other than** the Wallets screen (e.g., Send screen, Identities screen, Tokens screen) + +### Steps +1. Launch the application +2. Navigate to the **Send** screen (or any non-Wallets screen that can trigger an RPC call) +3. Initiate a payment or other operation that triggers a wallet-related RPC call for the legacy DET wallet (e.g., attempt to send funds) +4. Observe the application behavior + +### Expected Result +- Step 3: The RPC call fails with error -19 ("Wallet file not specified") +- **No SelectionDialog modal appears** -- dialogs are only shown on the Wallets screen +- An error banner is displayed with a message such as: "Core wallet not configured. Go to Wallets screen and refresh." +- The operation fails gracefully -- no crash, hang, or spinner stuck +- No `core_wallet_name` is persisted to SQLite (the error is informational only) +- The banner remains visible until the user navigates away or dismisses it + +--- + +## MWT-020: User resolves error -19 by following banner instructions + +| Field | Value | +|---|---| +| **ID** | MWT-020 | +| **Title** | User follows error banner advice to resolve Core wallet association | +| **Priority** | P0 | + +### Prerequisites +- Dash Core running with **two or more** wallets loaded +- A DET wallet with `core_wallet_name = NULL` in SQLite (legacy wallet) +- The user has encountered the error banner from MWT-019 on a non-Wallets screen + +### Steps +1. Starting from the state where the error banner "Core wallet not configured. Go to Wallets screen and refresh." is visible on a non-Wallets screen +2. Navigate to the **Wallets screen** using the sidebar/navigation +3. Select the affected DET wallet +4. Click **Refresh** (or trigger any wallet-related RPC operation) +5. Observe the auto-detection and/or SelectionDialog flow (as per MWT-001, MWT-002, or MWT-016) +6. Complete the Core wallet selection (auto-detected or manually via dialog) +7. Navigate back to the original screen (e.g., Send screen) +8. Re-attempt the operation that previously failed + +### Expected Result +- Step 4: The Wallets screen handles error -19 with its full recovery flow (auto-detection, then dialog if needed) +- Step 6: `core_wallet_name` is persisted to SQLite for the DET wallet +- Step 7: The error banner is no longer shown on the original screen +- Step 8: The operation succeeds because the DET wallet now has a valid `core_wallet_name` in SQLite; RPC calls use `/wallet/` and no error -19 occurs +- The full round-trip (error on non-Wallets screen -> navigate to Wallets -> resolve -> return -> retry) works without application restart + +--- + +## MWT-021: Error -19 on Identities screen shows banner (no dialog) + +| Field | Value | +|---|---| +| **ID** | MWT-021 | +| **Title** | Error -19 on Identities screen shows error banner without dialog | +| **Priority** | P1 | + +### Prerequisites +- Dash Core running with **two or more** wallets loaded +- A DET wallet with `core_wallet_name = NULL` in SQLite +- An identity associated with the DET wallet exists in the application +- The user is on the **Identities** screen + +### Steps +1. Navigate to the **Identities** screen +2. Trigger an identity operation that requires a Core RPC call (e.g., top-up identity, register username, create asset lock) +3. Observe the application behavior + +### Expected Result +- The RPC call fails with error -19 +- **No SelectionDialog modal appears** +- An error banner is displayed: "Core wallet not configured. Go to Wallets screen and refresh." +- The identity operation fails gracefully with the banner message +- The user can resolve the issue by following MWT-020 steps + +--- + +## MWT-022: Error -19 on Tokens screen shows banner (no dialog) + +| Field | Value | +|---|---| +| **ID** | MWT-022 | +| **Title** | Error -19 on Tokens screen shows error banner without dialog | +| **Priority** | P1 | + +### Prerequisites +- Dash Core running with **two or more** wallets loaded +- A DET wallet with `core_wallet_name = NULL` in SQLite +- The user is on the **Tokens** screen + +### Steps +1. Navigate to the **Tokens** screen +2. Trigger a token operation that requires a Core RPC call (e.g., mint, transfer, or burn tokens) +3. Observe the application behavior + +### Expected Result +- The RPC call fails with error -19 +- **No SelectionDialog modal appears** +- An error banner is displayed: "Core wallet not configured. Go to Wallets screen and refresh." +- The token operation fails gracefully +- The user can resolve the issue by following MWT-020 steps + +--- + +## MWT-023: Dialog does NOT appear on app.rs level (global scope removed) + +| Field | Value | +|---|---| +| **ID** | MWT-023 | +| **Title** | No global-level SelectionDialog appears regardless of which screen triggers error -19 | +| **Priority** | P0 | + +### Prerequisites +- Dash Core running with **two or more** wallets loaded +- A DET wallet with `core_wallet_name = NULL` in SQLite +- The application is running on various screens + +### Steps +1. Navigate to the **Send** screen and trigger an operation that results in error -19 +2. Observe: no dialog appears, only an error banner +3. Navigate to the **Identities** screen and trigger an operation that results in error -19 +4. Observe: no dialog appears, only an error banner +5. Navigate to the **Tokens** screen and trigger an operation that results in error -19 +6. Observe: no dialog appears, only an error banner +7. Navigate to the **Wallets** screen and trigger an operation that results in error -19 +8. Observe: the SelectionDialog modal appears (or auto-detection resolves it) + +### Expected Result +- Steps 1-6: On every non-Wallets screen, error -19 produces an error banner -- never a dialog +- Steps 7-8: On the Wallets screen only, the full recovery flow (auto-detection + dialog fallback) is triggered +- The `app.rs` global handler no longer intercepts `CoreWalletSelectionNeeded` to show a dialog +- This confirms the dialog has been moved from global scope to Wallets-screen-only scope diff --git a/src/app.rs b/src/app.rs index 72b235954..2d3bcbb37 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1148,18 +1148,30 @@ impl App for AppState { } } } + TaskResult::Error(TaskError::MustRetry(msg)) => { + MessageBanner::set_global(ctx, &msg, MessageType::Success); + self.visible_screen_mut() + .display_message(&msg, MessageType::Success); + self.visible_screen_mut().refresh(); + } TaskResult::Error(err) => { - let msg = err.to_string(); - let handle = MessageBanner::set_global(ctx, &msg, MessageType::Error); - if self.current_app_context().is_developer_mode() { - // INTENTIONAL(SEC-003): TaskError Debug output is shown to users - // in developer mode. This is a local UI app — no third parties - // see this output. Ensure inner error types don't expose secrets - // (see #667). - handle.with_details(&err); + // Let the screen handle specific error types first. + // If handled, skip the generic error banner. + let handled = self.visible_screen_mut().display_task_error(&err); + + if !handled { + let msg = err.to_string(); + let handle = MessageBanner::set_global(ctx, &msg, MessageType::Error); + if self.current_app_context().is_developer_mode() { + // INTENTIONAL(SEC-003): TaskError Debug output is shown to users + // in developer mode. This is a local UI app — no third parties + // see this output. Ensure inner error types don't expose secrets + // (see #667). + handle.with_details(&err); + } + self.visible_screen_mut() + .display_message(&msg, MessageType::Error); } - self.visible_screen_mut() - .display_message(&msg, MessageType::Error); } TaskResult::Refresh => { self.visible_screen_mut().refresh(); diff --git a/src/backend_task/core/mod.rs b/src/backend_task/core/mod.rs index ef281a7bb..f565f2755 100644 --- a/src/backend_task/core/mod.rs +++ b/src/backend_task/core/mod.rs @@ -7,6 +7,7 @@ mod start_dash_qt; use crate::app_dir::core_cookie_path; use crate::backend_task::BackendTaskSuccessResult; +use crate::backend_task::error::TaskError; use crate::config::{Config, NetworkConfig}; use crate::context::AppContext; use crate::model::wallet::Wallet; @@ -151,10 +152,18 @@ pub enum CoreItem { } impl AppContext { + /// Extract the seed hash and first known address from an HD wallet. + fn core_wallet_first_address( + wallet: &Arc>, + ) -> Result<([u8; 32], Option
), String> { + let g = wallet.read().map_err(|e| e.to_string())?; + Ok((g.seed_hash(), g.known_addresses.keys().next().cloned())) + } + pub async fn run_core_task( self: &Arc, task: CoreTask, - ) -> Result { + ) -> Result { match task { CoreTask::GetBestChainLock => self .core_client @@ -167,10 +176,10 @@ impl AppContext { self.network, )) }) - .map_err(|e| e.to_string()), + .map_err(TaskError::from), CoreTask::GetBestChainLocks => { // Load configs - let config = Config::load().map_err(|e| format!("Failed to load config: {}", e))?; + let config = Config::load()?; let maybe_mainnet_config = config.config_for_network(Network::Dash); let maybe_testnet_config = config.config_for_network(Network::Testnet); @@ -198,26 +207,32 @@ impl AppContext { ))) } CoreTask::RefreshWalletInfo(wallet, sync_platform) => { - // Get wallet seed hash for Platform balance refresh - let seed_hash = { - let wallet_guard = wallet.read().map_err(|e| e.to_string())?; - wallet_guard.seed_hash() - }; + 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 - .map_err(|e| format!("Error refreshing wallet via SPV: {}", e))?; + self.reconcile_spv_wallets().await?; } else { - // Run blocking RPC calls on a dedicated thread pool to avoid freezing the UI let ctx = self.clone(); - tokio::task::spawn_blocking(move || ctx.refresh_wallet_info(wallet)) - .await - .map_err(|e| format!("Task join error: {}", e))? - .map_err(|e| format!("Error refreshing wallet: {}", e))?; + 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?; + } + } } - // Also refresh Platform address balances if requested let warning = if sync_platform { match self.fetch_platform_address_balances(seed_hash).await { Ok(_) => None, @@ -233,38 +248,74 @@ impl AppContext { Ok(BackendTaskSuccessResult::RefreshedWallet { warning }) } CoreTask::RefreshSingleKeyWalletInfo(wallet) => { - // Run blocking RPC calls on a dedicated thread pool to avoid freezing the UI + let (key_hash, address) = { + let g = wallet.read().map_err(|e| TaskError::from(e.to_string()))?; + (g.key_hash, g.address.clone()) + }; + let wallet_for_retry = wallet.clone(); let ctx = self.clone(); - tokio::task::spawn_blocking(move || ctx.refresh_single_key_wallet_info(wallet)) - .await - .map_err(|e| format!("Task join error: {}", e))? - .map_err(|e| format!("Error refreshing wallet: {}", e))?; - Ok(BackendTaskSuccessResult::RefreshedWallet { warning: None }) + 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, + } } CoreTask::StartDashQT(network, custom_dash_qt, overwrite_dash_conf) => self .start_dash_qt(network, custom_dash_qt, overwrite_dash_conf) - .map_err(|e| e.to_string()) + .map_err(|e| TaskError::from(e.to_string())) .map(|_| BackendTaskSuccessResult::None), - CoreTask::CreateRegistrationAssetLock(wallet, amount, identity_index) => self - .create_registration_asset_lock(wallet, amount, true, identity_index) - .await - .map_err(|e| format!("Error creating asset lock: {}", e)), - CoreTask::CreateTopUpAssetLock(wallet, amount, identity_index, top_up_index) => self - .create_top_up_asset_lock(wallet, amount, true, identity_index, top_up_index) - .await - .map_err(|e| format!("Error creating top up asset lock: {}", e)), + 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 + .map_err(TaskError::from); + self.with_wallet_recovery(&seed_hash, first_addr.as_ref(), false, result) + } + 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 + .map_err(TaskError::from); + self.with_wallet_recovery(&seed_hash, first_addr.as_ref(), false, result) + } CoreTask::SendWalletPayment { wallet, request } => { - self.send_wallet_payment(wallet, request).await + let (seed_hash, first_addr) = Self::core_wallet_first_address(&wallet)?; + let result = self + .send_wallet_payment(wallet, request) + .await + .map_err(TaskError::from); + self.with_wallet_recovery(&seed_hash, first_addr.as_ref(), false, result) } CoreTask::SendSingleKeyWalletPayment { wallet, request } => { - self.send_single_key_wallet_payment(wallet, request).await + let (key_hash, address) = { + let g = wallet.read().map_err(|e| TaskError::from(e.to_string()))?; + (g.key_hash, g.address.clone()) + }; + let result = self + .send_single_key_wallet_payment(wallet, request) + .await + .map_err(TaskError::from); + self.with_wallet_recovery(&key_hash, Some(&address), true, result) } CoreTask::RecoverAssetLocks(wallet) => { - // Run blocking RPC calls on a dedicated thread pool to avoid freezing the UI + let (seed_hash, first_addr) = Self::core_wallet_first_address(&wallet)?; let ctx = self.clone(); - tokio::task::spawn_blocking(move || ctx.recover_asset_locks(wallet)) - .await - .map_err(|e| format!("Task join error: {}", e))? + 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) } CoreTask::MineBlocks { block_count, @@ -272,33 +323,112 @@ impl AppContext { wallet, } => { if !matches!(self.network, Network::Regtest | Network::Devnet) { - return Err("Mining is only available on Regtest and Devnet".to_string()); + return Err(TaskError::from( + "Mining is only available on Regtest and Devnet".to_string(), + )); } let ctx = self.clone(); let mined = tokio::task::spawn_blocking(move || { ctx.core_client .read() - .map_err(|e| format!("Core client lock was poisoned: {}", e))? + .map_err(|e| { + TaskError::from(format!("Core client lock was poisoned: {}", e)) + })? .generate_to_address(block_count, &address) - .map_err(|e| e.to_string()) + .map_err(TaskError::from) }) - .await - .map_err(|e| format!("Task join error: {}", e))??; + .await??; 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 - .map_err(|e| format!("Task join error: {}", e))? - .map_err(|e| format!("Error refreshing wallet after mining: {}", e))?; + .await? + .map_err(|e| { + TaskError::from(format!("Error refreshing wallet after mining: {}", e)) + })?; Ok(BackendTaskSuccessResult::MineBlocksSuccess(mined_count)) } } } + /// 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, + ) -> Result { + 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::from( + "Wallet not found in database when persisting Core wallet name" + .to_string(), + )); + } + 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::from( + "Wallet not found in database when persisting Core wallet name" + .to_string(), + )); + } + 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, network: Network, diff --git a/src/backend_task/core/recover_asset_locks.rs b/src/backend_task/core/recover_asset_locks.rs index 77fc87981..efd3360b4 100644 --- a/src/backend_task/core/recover_asset_locks.rs +++ b/src/backend_task/core/recover_asset_locks.rs @@ -1,4 +1,5 @@ use crate::backend_task::BackendTaskSuccessResult; +use crate::backend_task::error::TaskError; use crate::context::AppContext; use crate::model::wallet::Wallet; use dash_sdk::dashcore_rpc::RpcApi; @@ -16,8 +17,8 @@ impl AppContext { pub fn recover_asset_locks( &self, wallet: Arc>, - ) -> Result { - let (known_addresses, seed_hash, already_tracked_txids) = { + ) -> Result { + let (known_addresses, seed_hash, already_tracked_txids, core_wallet_name) = { let wallet_guard = wallet.read().map_err(|e| e.to_string())?; let addresses: Vec
= wallet_guard.known_addresses.keys().cloned().collect(); let tracked: HashSet<_> = wallet_guard @@ -25,7 +26,12 @@ impl AppContext { .iter() .map(|(tx, _, _, _, _)| tx.txid()) .collect(); - (addresses, wallet_guard.seed_hash(), tracked) + ( + addresses, + wallet_guard.seed_hash(), + tracked, + wallet_guard.core_wallet_name.clone(), + ) }; tracing::info!( @@ -42,10 +48,7 @@ impl AppContext { }); } - let client = self - .core_client - .read() - .expect("Core client lock was poisoned"); + let client = self.core_client_for_wallet(core_wallet_name.as_deref())?; let mut recovered_count = 0; let mut total_amount = 0u64; @@ -59,9 +62,7 @@ impl AppContext { // 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) - .map_err(|e| format!("Failed to list unspent: {}", e))?; + let unspent = client.list_unspent(None, None, Some(&address_refs), Some(true), None)?; tracing::info!( "Found {} unspent outputs for known addresses", diff --git a/src/backend_task/core/refresh_single_key_wallet_info.rs b/src/backend_task/core/refresh_single_key_wallet_info.rs index fb8cc4c54..5ee6c868c 100644 --- a/src/backend_task/core/refresh_single_key_wallet_info.rs +++ b/src/backend_task/core/refresh_single_key_wallet_info.rs @@ -1,5 +1,6 @@ //! 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 dash_sdk::dashcore_rpc::RpcApi; @@ -12,35 +13,28 @@ impl AppContext { pub fn refresh_single_key_wallet_info( &self, wallet: Arc>, - ) -> Result<(), String> { + ) -> Result<(), TaskError> { // Step 1: Get the address from the wallet - let (address, key_hash) = { + let (address, key_hash, core_wallet_name) = { let wallet_guard = wallet.read().map_err(|e| e.to_string())?; - (wallet_guard.address.clone(), wallet_guard.key_hash) + ( + wallet_guard.address.clone(), + wallet_guard.key_hash, + wallet_guard.core_wallet_name.clone(), + ) }; - // Step 2: Import address to Core (needed for UTXO queries) - { - let client = self - .core_client - .read() - .expect("Core client lock was poisoned"); + // Build an RPC client targeting the wallet's Core wallet (if set) + 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"); - } + // Step 2: Import address to Core (needed for UTXO queries) + if let Err(e) = client.import_address(&address, None, Some(false)) { + tracing::debug!(?e, address = %address, "import_address failed during single key refresh"); } // Step 3: Get UTXOs for this address let utxo_map = { - let client = self - .core_client - .read() - .expect("Core client lock was poisoned"); - - let utxos = client - .list_unspent(Some(0), None, Some(&[&address]), None, None) - .map_err(|e| format!("Failed to list UTXOs: {}", e))?; + let utxos = client.list_unspent(Some(0), None, Some(&[&address]), None, None)?; let mut map: HashMap = HashMap::new(); for utxo in utxos { diff --git a/src/backend_task/core/refresh_wallet_info.rs b/src/backend_task/core/refresh_wallet_info.rs index f1b7eaa4d..9d26d477a 100644 --- a/src/backend_task/core/refresh_wallet_info.rs +++ b/src/backend_task/core/refresh_wallet_info.rs @@ -1,4 +1,5 @@ use crate::backend_task::BackendTaskSuccessResult; +use crate::backend_task::error::TaskError; use crate::context::AppContext; use crate::model::wallet::{DerivationPathHelpers, Wallet}; use dash_sdk::dashcore_rpc::RpcApi; @@ -15,9 +16,9 @@ impl AppContext { pub fn refresh_wallet_info( &self, wallet: Arc>, - ) -> Result { + ) -> Result { // Step 1: Collect data from wallet with brief read lock - let (addresses, asset_lock_txs, seed_hash) = { + let (addresses, asset_lock_txs, seed_hash, core_wallet_name) = { let wallet_guard = wallet.read().map_err(|e| e.to_string())?; let addrs = wallet_guard .known_addresses @@ -31,44 +32,34 @@ impl AppContext { .map(|(tx, _, _, _, _)| tx.clone()) .collect(); let seed = wallet_guard.seed_hash(); - (addrs, asset_locks, seed) + let cwn = wallet_guard.core_wallet_name.clone(); + (addrs, asset_locks, seed, cwn) }; // Read lock released here - // Step 2: Import addresses to Core (no wallet lock needed) - { - let client = self - .core_client - .read() - .expect("Core client lock was poisoned"); + // Build an RPC client targeting the wallet's Core wallet (if set) + let client = self.core_client_for_wallet(core_wallet_name.as_deref())?; - 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 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 = { - let client = self - .core_client - .read() - .expect("Core client lock was poisoned"); - // Get UTXOs for all addresses let utxos = if addresses.is_empty() { Vec::new() } else { - client - .list_unspent( - None, - None, - Some(&addresses.iter().collect::>()), - Some(false), - None, - ) - .map_err(|e| format!("Failed to list UTXOs: {}", e))? + client.list_unspent( + None, + None, + Some(&addresses.iter().collect::>()), + Some(false), + None, + )? }; // Build the UTXO map @@ -96,11 +87,6 @@ impl AppContext { // Step 5: Fetch total received for each address from Core RPC (no wallet lock) let mut total_received_map: HashMap = HashMap::new(); { - let client = self - .core_client - .read() - .expect("Core client lock was poisoned"); - for address in &addresses { match client.get_received_by_address(address, None) { Ok(amount) => { @@ -119,11 +105,6 @@ impl AppContext { // Step 6: Check which asset locks are stale (no wallet lock needed) let stale_txids: Vec<_> = { - let client = self - .core_client - .read() - .expect("Core client lock was poisoned"); - asset_lock_txs .iter() .filter_map(|tx| { diff --git a/src/backend_task/dashpay.rs b/src/backend_task/dashpay.rs index d5c8d0ee5..82e489cf9 100644 --- a/src/backend_task/dashpay.rs +++ b/src/backend_task/dashpay.rs @@ -1,4 +1,5 @@ use crate::backend_task::BackendTaskSuccessResult; +use crate::backend_task::error::TaskError; use crate::context::AppContext; use dash_sdk::Sdk; use std::sync::Arc; @@ -99,76 +100,76 @@ impl AppContext { self: &Arc, task: DashPayTask, sdk: &Sdk, - ) -> Result { + ) -> Result { match task { DashPayTask::LoadProfile { identity } => { - profile::load_profile(self, sdk, identity).await + Ok(profile::load_profile(self, sdk, identity).await?) } DashPayTask::UpdateProfile { identity, display_name, bio, avatar_url, - } => profile::update_profile(self, sdk, identity, display_name, bio, avatar_url).await, + } => Ok( + profile::update_profile(self, sdk, identity, display_name, bio, avatar_url).await?, + ), DashPayTask::LoadContacts { identity } => { - contacts::load_contacts(self, sdk, identity).await + Ok(contacts::load_contacts(self, sdk, identity).await?) } DashPayTask::LoadContactRequests { identity } => { - contact_requests::load_contact_requests(self, sdk, identity).await + Ok(contact_requests::load_contact_requests(self, sdk, identity).await?) } DashPayTask::FetchContactProfile { identity, contact_id, - } => profile::fetch_contact_profile(self, sdk, identity, contact_id).await, + } => Ok(profile::fetch_contact_profile(self, sdk, identity, contact_id).await?), DashPayTask::SearchProfiles { search_query } => { - profile::search_profiles(self, sdk, search_query).await + Ok(profile::search_profiles(self, sdk, search_query).await?) } DashPayTask::SendContactRequest { identity, signing_key, to_username, account_label, - } => { - contact_requests::send_contact_request( - self, - sdk, - identity, - signing_key, - to_username, - account_label, - ) - .await - } + } => Ok(contact_requests::send_contact_request( + self, + sdk, + identity, + signing_key, + to_username, + account_label, + ) + .await?), DashPayTask::SendContactRequestWithProof { identity, signing_key, to_identity_id, account_label, qr_auto_accept, - } => { - contact_requests::send_contact_request_with_proof( - self, - sdk, - identity, - signing_key, - to_identity_id.to_string( - dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58, - ), - account_label, - Some(qr_auto_accept), - ) - .await - } + } => Ok(contact_requests::send_contact_request_with_proof( + self, + sdk, + identity, + signing_key, + to_identity_id + .to_string(dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58), + account_label, + Some(qr_auto_accept), + ) + .await?), DashPayTask::AcceptContactRequest { identity, request_id, - } => contact_requests::accept_contact_request(self, sdk, identity, request_id).await, + } => Ok( + contact_requests::accept_contact_request(self, sdk, identity, request_id).await?, + ), DashPayTask::RejectContactRequest { identity, request_id, - } => contact_requests::reject_contact_request(self, sdk, identity, request_id).await, + } => Ok( + contact_requests::reject_contact_request(self, sdk, identity, request_id).await?, + ), DashPayTask::LoadPaymentHistory { identity } => { - // Reuse the shared payment history loader to avoid duplicating logic. let identity_id = identity.identity.id(); let records = payments::load_payment_history(self, &identity_id, None).await?; @@ -236,19 +237,17 @@ impl AppContext { note, is_hidden, accepted_accounts, - } => { - contact_info::create_or_update_contact_info( - self, - sdk, - identity, - contact_id, - nickname, - note, - is_hidden, - accepted_accounts, - ) - .await - } + } => Ok(contact_info::create_or_update_contact_info( + self, + sdk, + identity, + contact_id, + nickname, + note, + is_hidden, + accepted_accounts, + ) + .await?), DashPayTask::RegisterDashPayAddresses { identity } => { let result = incoming_payments::register_dashpay_addresses_for_identity(self, &identity) diff --git a/src/backend_task/dashpay/payments.rs b/src/backend_task/dashpay/payments.rs index 4122acc7b..04906cf09 100644 --- a/src/backend_task/dashpay/payments.rs +++ b/src/backend_task/dashpay/payments.rs @@ -1,6 +1,7 @@ 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::qualified_identity::QualifiedIdentity; use dash_sdk::Sdk; @@ -210,7 +211,7 @@ pub async fn send_payment_to_contact( to_contact_id: Identifier, amount_dash: f64, memo: Option, -) -> Result { +) -> Result { send_payment_to_contact_impl( app_context, sdk, @@ -231,7 +232,7 @@ pub async fn send_payment_to_contact_impl( to_contact_id: Identifier, amount_dash: f64, memo: Option, -) -> Result { +) -> Result { use crate::backend_task::core::{CoreTask, PaymentRecipient, WalletPaymentRequest}; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; @@ -250,7 +251,9 @@ pub async fn send_payment_to_contact_impl( { let wallet_guard = wallet.read().map_err(|e| e.to_string())?; if !wallet_guard.is_open() { - return Err("Wallet must be unlocked to send a payment".to_string()); + return Err("Wallet must be unlocked to send a payment" + .to_string() + .into()); } } diff --git a/src/backend_task/dashpay/profile.rs b/src/backend_task/dashpay/profile.rs index 207861f0b..15b242e1a 100644 --- a/src/backend_task/dashpay/profile.rs +++ b/src/backend_task/dashpay/profile.rs @@ -346,7 +346,6 @@ pub async fn send_payment( amount_dash: f64, memo: Option, ) -> Result { - // Use the new payments module to send payment super::payments::send_payment_to_contact( app_context, sdk, @@ -356,6 +355,7 @@ pub async fn send_payment( memo, ) .await + .map_err(|e| e.to_string()) } pub async fn load_payment_history( diff --git a/src/backend_task/error.rs b/src/backend_task/error.rs index a77e3579d..435a1d97c 100644 --- a/src/backend_task/error.rs +++ b/src/backend_task/error.rs @@ -3,9 +3,14 @@ //! `Display` → user-friendly text (shown in `MessageBanner`). //! `Debug` → variant name + fields (logged and shown in collapsible details). //! `From` → backwards compatible with existing `Result` code. +//! Parses known error patterns into typed variants automatically. +use dash_sdk::dashcore_rpc; use thiserror::Error; +/// Dash Core RPC error code: wallet file not specified (multi-wallet node). +const RPC_WALLET_NOT_SPECIFIED: i32 = -19; + /// App-level error envelope for backend tasks. #[derive(Debug, Error)] pub enum TaskError { @@ -36,10 +41,111 @@ pub enum TaskError { /// Wallet errors. #[error(transparent)] Wallet(#[from] crate::database::WalletError), + + /// SQLite errors. + #[error(transparent)] + Sqlite(#[from] rusqlite::Error), + + /// Tokio task join errors. + #[error(transparent)] + JoinError(#[from] tokio::task::JoinError), + + /// Core wallet not configured for this wallet on a multi-wallet Core node. + #[error( + "Core wallet not configured for this wallet. Go to the Wallets screen and refresh to auto-detect the Core wallet association." + )] + CoreWalletNotConfigured, + + /// The operation's prerequisite was auto-fixed (e.g., Core wallet detected). + /// Callers should retry the failed operation. + #[error("{0}")] + MustRetry(String), } impl From for TaskError { fn from(s: String) -> Self { - TaskError::Generic(s) + if s.contains("Wallet file not specified") { + TaskError::CoreWalletNotConfigured + } else { + TaskError::Generic(s) + } + } +} + +impl From for TaskError { + fn from(e: dashcore_rpc::Error) -> Self { + if let dashcore_rpc::Error::JsonRpc(dashcore_rpc::jsonrpc::error::Error::Rpc(ref rpc_err)) = + e + && rpc_err.code == RPC_WALLET_NOT_SPECIFIED + { + return TaskError::CoreWalletNotConfigured; + } + TaskError::Generic(e.to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn from_string_detects_rpc_error_minus_19() { + let err: TaskError = "JSON-RPC error: RPC error response: RpcError { code: -19, message: \"Wallet file not specified\" }".to_string().into(); + assert!( + matches!(err, TaskError::CoreWalletNotConfigured), + "Expected CoreWalletNotConfigured, got: {err:?}", + ); + } + + #[test] + fn from_string_passes_through_other_errors() { + let err: TaskError = "Connection refused".to_string().into(); + assert!( + matches!(err, TaskError::Generic(ref s) if s == "Connection refused"), + "Expected Generic, got: {err:?}", + ); + } + + #[test] + fn display_message_is_user_friendly() { + let msg = TaskError::CoreWalletNotConfigured.to_string(); + assert!(msg.contains("Wallets screen")); + assert!(msg.contains("refresh")); + } + + #[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'"); + } + + #[test] + fn rpc_error_code_neg19_converts_to_core_wallet_not_configured() { + let rpc_err = dashcore_rpc::jsonrpc::error::RpcError { + code: -19, + message: "Wallet file not specified".to_string(), + data: None, + }; + let err: TaskError = + dashcore_rpc::Error::JsonRpc(dashcore_rpc::jsonrpc::error::Error::Rpc(rpc_err)).into(); + assert!( + matches!(err, TaskError::CoreWalletNotConfigured), + "Expected CoreWalletNotConfigured, got: {err:?}" + ); + } + + #[test] + fn other_rpc_error_converts_to_generic() { + let rpc_err = dashcore_rpc::jsonrpc::error::RpcError { + code: -1, + message: "Some other error".to_string(), + data: None, + }; + let err: TaskError = + dashcore_rpc::Error::JsonRpc(dashcore_rpc::jsonrpc::error::Error::Rpc(rpc_err)).into(); + assert!( + matches!(err, TaskError::Generic(_)), + "Expected Generic, got: {err:?}" + ); } } diff --git a/src/context/mod.rs b/src/context/mod.rs index 062f2eac7..09f6817cb 100644 --- a/src/context/mod.rs +++ b/src/context/mod.rs @@ -8,6 +8,7 @@ mod wallet_lifecycle; pub(crate) use transaction_processing::get_transaction_info; use crate::app_dir::core_cookie_path; +use crate::backend_task::error::TaskError; use crate::components::core_zmq_listener::ZMQConnectionEvent; use crate::config::{Config, NetworkConfig}; use crate::context_provider::Provider as RpcProvider; @@ -24,8 +25,8 @@ use arc_swap::ArcSwap; use connection_status::ConnectionStatus; use crossbeam_channel::{Receiver, Sender}; use dash_sdk::Sdk; -use dash_sdk::dashcore_rpc::{Auth, Client}; -use dash_sdk::dpp::dashcore::{Network, Txid}; +use dash_sdk::dashcore_rpc::{Auth, Client, RpcApi}; +use dash_sdk::dpp::dashcore::{Address, Network, Txid}; use dash_sdk::dpp::prelude::AssetLockProof; use dash_sdk::dpp::state_transition::StateTransitionSigningOptions; use dash_sdk::dpp::state_transition::batch_transition::methods::StateTransitionCreationOptions; @@ -52,8 +53,6 @@ pub(crate) type SettingsCacheGuard<'a> = RwLockWriteGuard<'a, Option>; pub struct AppContext { pub(crate) network: Network, developer_mode: AtomicBool, - #[allow(dead_code)] // May be used for devnet identification - pub(crate) devnet_name: Option, pub(crate) db: Arc, pub(crate) sdk: ArcSwap, // Context providers for SDK, so we can switch when backend mode changes @@ -199,38 +198,19 @@ impl AppContext { "http://{}:{}", network_config.core_host, network_config.core_rpc_port ); - let cookie_path = match core_cookie_path(network, &network_config.devnet_name) { - Ok(p) => p, + let core_client = match Self::create_core_rpc_client( + &addr, + network, + &network_config.devnet_name, + &network_config, + ) { + Ok(client) => client, Err(e) => { - tracing::error!(?network, "Failed to get core cookie path: {e}"); + tracing::error!(?network, "Failed to create CoreClient: {e}"); return None; } }; - // Try cookie authentication first, then user/password - let core_client = match Client::new(&addr, Auth::CookieFile(cookie_path.clone())) { - Ok(client) => client, - Err(_) => { - tracing::info!( - "Failed to authenticate using .cookie file at {:?}, falling back to user/pass", - cookie_path, - ); - match Client::new( - &addr, - Auth::UserPass( - network_config.core_rpc_user.to_string(), - network_config.core_rpc_password.to_string(), - ), - ) { - Ok(client) => client, - Err(e) => { - tracing::error!(?network, "Failed to create CoreClient: {e}"); - return None; - } - } - } - }; - let wallets: BTreeMap<_, _> = match db.get_wallets(&network) { Ok(w) => w, Err(e) => { @@ -306,7 +286,6 @@ impl AppContext { let app_context = AppContext { network, developer_mode: AtomicBool::new(developer_mode_enabled), - devnet_name: None, db, sdk: ArcSwap::from_pointee(sdk), spv_context_provider: spv_provider.into(), @@ -562,6 +541,123 @@ impl AppContext { Ok(()) } + + /// Create a Core RPC client for the given URL, trying cookie authentication + /// first and falling back to user/password credentials. + fn create_core_rpc_client( + url: &str, + network: Network, + devnet_name: &Option, + cfg: &NetworkConfig, + ) -> Result { + if let Ok(cookie_path) = core_cookie_path(network, devnet_name) { + if let Ok(client) = Client::new(url, Auth::CookieFile(cookie_path.clone())) { + return Ok(client); + } + tracing::debug!( + "Failed to authenticate using .cookie file at {:?}, falling back to user/pass", + cookie_path, + ); + } + Client::new( + url, + Auth::UserPass(cfg.core_rpc_user.clone(), cfg.core_rpc_password.clone()), + ) + .map_err(|e| format!("Failed to create Core RPC client: {e}").into()) + } + + /// 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 { + let cfg = self + .config + .read() + .map_err(|_| "Config lock poisoned".to_string())?; + let base = format!("http://{}:{}", cfg.core_host, cfg.core_rpc_port); + let url = match wallet_name { + Some(name) if !name.is_empty() => { + if name.contains("..") { + return Err(format!("Invalid Core wallet name: '{}'", name).into()); + } + let encoded = urlencoding::encode(name); + format!("{}/wallet/{}", base, encoded) + } + _ => base, + }; + Self::create_core_rpc_client(&url, self.network, &cfg.devnet_name, &cfg) + } + + /// 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. + pub fn ensure_address_imported( + &self, + address: &Address, + core_wallet_name: Option<&str>, + label: Option<&str>, + ) -> Result<(), TaskError> { + let client = self.core_client_for_wallet(core_wallet_name)?; + let info = client.get_address_info(address)?; + if !(info.is_watchonly || info.is_mine) { + client.import_address(address, label, Some(false))?; + } + Ok(()) + } + + /// Import address into Core, ignoring errors. For best-effort registration. + pub fn try_import_address( + &self, + address: &Address, + core_wallet_name: Option<&str>, + label: Option<&str>, + ) { + if let Ok(client) = self.core_client_for_wallet(core_wallet_name) { + let _ = client.import_address(address, label, Some(false)); + } + } + + /// List wallets currently loaded in Dash Core. + pub fn list_core_wallets(&self) -> Result, TaskError> { + let client = self.core_client_for_wallet(None)?; + client + .list_wallets() + .map_err(|e| format!("Failed to list Core wallets: {e}").into()) + } + + /// Try to detect which loaded Core wallet owns the given address. + /// + /// 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, TaskError> { + let core_wallets = self.list_core_wallets()?; + if core_wallets.is_empty() { + return Err("No wallets loaded in Dash Core".to_string().into()); + } + if core_wallets.len() == 1 { + return Ok(Some(core_wallets.into_iter().next().unwrap())); + } + // 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"); + } + } + } + match matches.len() { + 1 => Ok(Some(matches.into_iter().next().unwrap())), + _ => Ok(None), // 0 or >1 matches — ambiguous + } + } } /// Returns the default platform version for the given network. @@ -575,3 +671,16 @@ pub(crate) const fn default_platform_version(network: &Network) -> &'static Plat _ => panic!("Unsupported network for default_platform_version"), } } + +#[cfg(test)] +mod tests { + #[test] + fn wallet_name_with_spaces_is_url_encoded() { + let base = "http://127.0.0.1:9998"; + let name = "my test wallet"; + let encoded = urlencoding::encode(name); + let url = format!("{}/wallet/{}", base, encoded); + assert_eq!(url, "http://127.0.0.1:9998/wallet/my%20test%20wallet"); + assert!(!url.contains(' ')); + } +} diff --git a/src/context/wallet_lifecycle.rs b/src/context/wallet_lifecycle.rs index f75428f1a..803ed5aab 100644 --- a/src/context/wallet_lifecycle.rs +++ b/src/context/wallet_lifecycle.rs @@ -183,13 +183,19 @@ impl AppContext { let ctx = Arc::clone(self); self.subtasks .spawn_sync("refresh_wallet_utxos", async move { - if let Err(e) = + let result = tokio::task::spawn_blocking(move || ctx.refresh_wallet_info(wallet)) - .await - .map_err(|e| format!("Task join error: {}", e)) - .and_then(|r| r.map(|_| ())) - { - tracing::warn!("Failed to auto-refresh wallet UTXOs on startup: {}", e); + .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(_)) => {} } }); } @@ -202,17 +208,20 @@ impl AppContext { let ctx = Arc::clone(self); self.subtasks .spawn_sync("refresh_single_key_wallet_utxos", async move { - if let Err(e) = tokio::task::spawn_blocking(move || { + let result = tokio::task::spawn_blocking(move || { ctx.refresh_single_key_wallet_info(wallet) }) - .await - .map_err(|e| format!("Task join error: {}", e)) - .and_then(|r| r) - { - tracing::warn!( + .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(())) => {} } }); } diff --git a/src/database/initialization.rs b/src/database/initialization.rs index 95be50b61..5588cd488 100644 --- a/src/database/initialization.rs +++ b/src/database/initialization.rs @@ -4,7 +4,7 @@ use rusqlite::{Connection, params}; use std::fs; use std::path::Path; -pub const DEFAULT_DB_VERSION: u16 = 27; +pub const DEFAULT_DB_VERSION: u16 = 28; pub const DEFAULT_NETWORK: &str = "dash"; @@ -51,6 +51,9 @@ impl Database { fn apply_version_changes(&self, version: u16, tx: &Connection) -> rusqlite::Result<()> { match version { + 28 => { + self.add_core_wallet_name_column(tx)?; + } 27 => { self.add_network_indexes(tx)?; } @@ -316,7 +319,8 @@ impl Database { total_balance INTEGER DEFAULT 0, last_platform_full_sync INTEGER DEFAULT 0, last_platform_sync_checkpoint INTEGER DEFAULT 0, - last_terminal_block INTEGER DEFAULT 0 + last_terminal_block INTEGER DEFAULT 0, + core_wallet_name TEXT DEFAULT NULL )", [], )?; @@ -838,6 +842,34 @@ impl Database { Ok(()) } + /// Migration: Add core_wallet_name column to wallet and single_key_wallet tables (version 28). + fn add_core_wallet_name_column(&self, conn: &Connection) -> rusqlite::Result<()> { + let wallet_has_column: bool = conn.query_row( + "SELECT COUNT(*) FROM pragma_table_info('wallet') WHERE name='core_wallet_name'", + [], + |row| row.get::<_, i32>(0).map(|count| count > 0), + )?; + if !wallet_has_column { + conn.execute( + "ALTER TABLE wallet ADD COLUMN core_wallet_name TEXT DEFAULT NULL", + [], + )?; + } + + let single_key_wallet_has_column: bool = conn.query_row( + "SELECT COUNT(*) FROM pragma_table_info('single_key_wallet') WHERE name='core_wallet_name'", + [], + |row| row.get::<_, i32>(0).map(|count| count > 0), + )?; + if !single_key_wallet_has_column { + conn.execute( + "ALTER TABLE single_key_wallet ADD COLUMN core_wallet_name TEXT DEFAULT NULL", + [], + )?; + } + Ok(()) + } + /// Migration: Add network indexes to high-traffic tables (version 27). /// These tables are frequently queried with WHERE network = ? but lacked indexes. fn add_network_indexes(&self, conn: &Connection) -> rusqlite::Result<()> { diff --git a/src/database/single_key_wallet.rs b/src/database/single_key_wallet.rs index 372bcfdb9..c12ad4318 100644 --- a/src/database/single_key_wallet.rs +++ b/src/database/single_key_wallet.rs @@ -24,7 +24,8 @@ impl Database { network TEXT NOT NULL, confirmed_balance INTEGER DEFAULT 0, unconfirmed_balance INTEGER DEFAULT 0, - total_balance INTEGER DEFAULT 0 + total_balance INTEGER DEFAULT 0, + core_wallet_name TEXT DEFAULT NULL )", [], )?; @@ -58,8 +59,9 @@ impl Database { network, confirmed_balance, unconfirmed_balance, - total_balance - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", + 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(), @@ -73,6 +75,7 @@ impl Database { wallet.confirmed_balance as i64, wallet.unconfirmed_balance as i64, wallet.total_balance as i64, + wallet.core_wallet_name.as_deref(), ], )?; Ok(()) @@ -97,7 +100,8 @@ impl Database { uses_password, confirmed_balance, unconfirmed_balance, - total_balance + total_balance, + core_wallet_name FROM single_key_wallet WHERE network = ?1", )?; @@ -114,6 +118,7 @@ impl Database { 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 = row.get(11)?; Ok(( key_hash_vec, @@ -127,6 +132,7 @@ impl Database { confirmed_balance, unconfirmed_balance, total_balance, + core_wallet_name, )) })?; @@ -145,6 +151,7 @@ impl Database { confirmed_balance, unconfirmed_balance, total_balance, + core_wallet_name, ) = row_result?; // Parse key hash @@ -189,6 +196,7 @@ impl Database { unconfirmed_balance: unconfirmed_balance as u64, total_balance: total_balance as u64, utxos: HashMap::new(), + core_wallet_name, }; wallets.push(wallet); @@ -249,6 +257,28 @@ impl Database { 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 { + 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, diff --git a/src/database/wallet.rs b/src/database/wallet.rs index bf8f040eb..953f408b9 100644 --- a/src/database/wallet.rs +++ b/src/database/wallet.rs @@ -33,8 +33,8 @@ impl Database { wallet.master_bip44_ecdsa_extended_public_key.encode(); self.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) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "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(), @@ -48,12 +48,35 @@ impl Database { network_str, wallet.confirmed_balance as i64, wallet.unconfirmed_balance as i64, - wallet.total_balance as i64 + wallet.total_balance as i64, + wallet.core_wallet_name.as_deref(), ], )?; Ok(()) } + /// 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 { + 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( @@ -419,7 +442,7 @@ impl Database { 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 FROM wallet WHERE network = ?", + "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 = ?", )?; let mut wallets_map: BTreeMap<[u8; 32], Wallet> = BTreeMap::new(); @@ -437,6 +460,7 @@ impl Database { let confirmed_balance: i64 = row.get::<_, Option>(9)?.unwrap_or(0); let unconfirmed_balance: i64 = row.get::<_, Option>(10)?.unwrap_or(0); let total_balance: i64 = row.get::<_, Option>(11)?.unwrap_or(0); + let core_wallet_name: Option = row.get(12)?; // Reconstruct the extended public keys let master_ecdsa_extended_public_key = @@ -512,6 +536,7 @@ impl Database { unconfirmed_balance: unconfirmed_balance as u64, total_balance: total_balance as u64, platform_address_info: BTreeMap::new(), + core_wallet_name, }, ); diff --git a/src/model/wallet/mod.rs b/src/model/wallet/mod.rs index 79ddaaadb..fb3d018c7 100644 --- a/src/model/wallet/mod.rs +++ b/src/model/wallet/mod.rs @@ -216,7 +216,6 @@ impl DerivationPathHelpers for DerivationPath { use crate::context::AppContext; use bitflags::bitflags; -use dash_sdk::dashcore_rpc::RpcApi; use dash_sdk::dpp::balances::credits::Duffs; use dash_sdk::dpp::dashcore::hashes::Hash; use dash_sdk::dpp::fee::Credits; @@ -329,6 +328,8 @@ pub struct Wallet { pub total_balance: u64, /// DIP-17: Platform address balances and nonces (keyed by Core Address for lookup) pub platform_address_info: BTreeMap, + /// Dash Core wallet name for multi-wallet RPC calls + pub core_wallet_name: Option, } #[derive(Debug, Clone, PartialEq)] @@ -737,23 +738,15 @@ impl Wallet { known_public_key = Some(public_key); if let Some(app_context) = register { let address = Address::p2pkh(&public_key, network); - app_context - .core_client - .read() - .expect("Core client lock was poisoned") - .import_address( - &address, - Some( - format!( - "Managed by Dash Evo Tool {} {}", - self.alias.clone().unwrap_or_default(), - derivation_path - ) - .as_str(), - ), - Some(false), - ) - .map_err(|e| e.to_string())?; + 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, @@ -944,10 +937,8 @@ impl Wallet { }, ); - if app_context.core_backend_mode() == crate::spv::CoreBackendMode::Rpc - && let Ok(client) = app_context.core_client.read() - { - let _ = client.import_address(&address, None, Some(false)); + 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!( @@ -2515,6 +2506,7 @@ mod tests { unconfirmed_balance: 0, total_balance: 0, platform_address_info: BTreeMap::new(), + core_wallet_name: None, } } diff --git a/src/model/wallet/single_key.rs b/src/model/wallet/single_key.rs index 1d5745d7c..1a4ad63e0 100644 --- a/src/model/wallet/single_key.rs +++ b/src/model/wallet/single_key.rs @@ -39,6 +39,8 @@ pub struct SingleKeyWallet { pub total_balance: u64, /// UTXOs for this address pub utxos: HashMap, + /// Dash Core wallet name for multi-wallet RPC calls + pub core_wallet_name: Option, } /// Private key data - either open (decrypted) or closed (encrypted) @@ -258,6 +260,7 @@ impl SingleKeyWallet { unconfirmed_balance: 0, total_balance: 0, utxos: HashMap::new(), + core_wallet_name: None, }) } diff --git a/src/model/wallet/utxos.rs b/src/model/wallet/utxos.rs index e80655435..5c406f0b2 100644 --- a/src/model/wallet/utxos.rs +++ b/src/model/wallet/utxos.rs @@ -123,9 +123,8 @@ impl Wallet { } let core_client = app_context - .core_client - .read() - .map_err(|e| format!("Core client lock was poisoned: {}", e))?; + .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. @@ -152,9 +151,6 @@ impl Wallet { .map_err(|e| e.to_string())? }; - // Drop the RPC client guard before the rest of the bookkeeping - drop(core_client); - // Initialize the HashMap to store the new UTXOs. let mut new_utxo_map = HashMap::new(); // Build a set of new OutPoints for easy comparison. diff --git a/src/ui/components/message_banner.rs b/src/ui/components/message_banner.rs index 015bcb109..72ccf48bd 100644 --- a/src/ui/components/message_banner.rs +++ b/src/ui/components/message_banner.rs @@ -361,6 +361,18 @@ impl MessageBanner { } } + /// Set a global error banner from any error type that implements `Display` + `Debug`. + /// + /// Uses `Display` for the user-facing message and attaches `Debug` as details. + pub fn set_global_with_error( + ctx: &egui::Context, + err: E, + ) -> BannerHandle { + let handle = Self::set_global(ctx, err.to_string(), MessageType::Error); + handle.with_details(err); + handle + } + /// Finds a message by `old_text` and replaces it with `new_text`. /// If `old_text` is not found, falls back to adding `new_text` as a new /// message (with dedup check). This fallback is intentional: callers use diff --git a/src/ui/components/mod.rs b/src/ui/components/mod.rs index 95dc26d8c..e6367d6b8 100644 --- a/src/ui/components/mod.rs +++ b/src/ui/components/mod.rs @@ -10,6 +10,7 @@ pub mod info_popup; pub mod left_panel; pub mod left_wallet_panel; pub mod message_banner; +pub mod selection_dialog; pub mod styled; pub mod tokens_subscreen_chooser_panel; pub mod tools_subscreen_chooser_panel; diff --git a/src/ui/components/selection_dialog.rs b/src/ui/components/selection_dialog.rs new file mode 100644 index 000000000..d7cbfa363 --- /dev/null +++ b/src/ui/components/selection_dialog.rs @@ -0,0 +1,408 @@ +use std::sync::Arc; + +use crate::ui::components::component_trait::{Component, ComponentResponse}; +use crate::ui::theme::{ComponentStyles, DashColors, Shape}; +use egui::{InnerResponse, Ui, WidgetText}; + +/// Result of a selection dialog interaction +#[derive(Debug, Clone, PartialEq)] +pub enum SelectionStatus { + /// User selected an option (0-indexed) + Selected(usize), + /// User cancelled the dialog + Canceled, +} + +pub const NOTHING: Option<&str> = None; + +/// Response struct for the SelectionDialog component following the Component trait pattern +#[derive(Debug, Clone)] +pub struct SelectionDialogComponentResponse { + pub response: egui::Response, + pub changed: bool, + pub error_message: Option, + pub dialog_response: Option, +} + +impl ComponentResponse for SelectionDialogComponentResponse { + type DomainType = SelectionStatus; + + fn has_changed(&self) -> bool { + self.changed + } + + fn is_valid(&self) -> bool { + self.error_message.is_none() + } + + fn changed_value(&self) -> &Option { + if self.has_changed() { + &self.dialog_response + } else { + &None + } + } + + fn error_message(&self) -> Option<&str> { + self.error_message.as_deref() + } +} + +/// A reusable selection dialog component that implements the Component trait. +/// +/// This component provides a modal dialog with a dropdown (ComboBox) for selecting +/// from a list of options. It follows the same visual pattern as `ConfirmationDialog` +/// but adds a selection mechanism between the message and buttons. +/// +/// Supports customizable title, message, option labels, button text (using WidgetText +/// for styling), and preselection. The dialog can be dismissed by pressing Escape +/// (treated as cancel) or clicking the X button. Enter confirms the current selection. +pub struct SelectionDialog { + title: WidgetText, + message: WidgetText, + options: Vec, + selected_index: usize, + confirm_text: Option, + cancel_text: Option, + status: Option, + is_open: bool, +} + +impl Component for SelectionDialog { + type DomainType = SelectionStatus; + type Response = SelectionDialogComponentResponse; + + fn show(&mut self, ui: &mut Ui) -> InnerResponse { + let inner_response = self.show_dialog(ui); + let changed = inner_response.inner.is_some(); + let response = inner_response.response; + + InnerResponse::new( + SelectionDialogComponentResponse { + response: response.clone(), + changed, + error_message: None, + dialog_response: inner_response.inner, + }, + response, + ) + } + + fn current_value(&self) -> Option { + if self.is_open { + None + } else { + self.status.clone() + } + } +} + +impl SelectionDialog { + /// Create a new selection dialog with the given title, message, and options + pub fn new( + title: impl Into, + message: impl Into, + options: Vec, + ) -> Self { + Self { + title: title.into(), + message: message.into(), + options, + selected_index: 0, + confirm_text: Some("Select".into()), + cancel_text: Some("Cancel".into()), + is_open: true, + status: None, + } + } + + /// Set the text for the confirm button, or None to hide it + pub fn confirm_text(mut self, text: Option>) -> Self { + self.confirm_text = text.map(|t| t.into()); + self + } + + /// Set the text for the cancel button, or None to hide it + pub fn cancel_text(mut self, text: Option>) -> Self { + self.cancel_text = text.map(|t| t.into()); + self + } + + /// Preselect an option by index (0-based). Clamped to valid range. + pub fn preselect(mut self, index: usize) -> Self { + if !self.options.is_empty() { + self.selected_index = index.min(self.options.len() - 1); + } + self + } + + /// Set whether the dialog is open + pub fn open(mut self, open: bool) -> Self { + self.is_open = open; + self + } + + /// Render the dialog as a modal overlay using its own `egui::Area`. + /// + /// Returns `Some(SelectionStatus)` when the user confirms or cancels, + /// `None` while the dialog is still open. + pub fn show_modal(&mut self, ctx: &egui::Context) -> Option { + use crate::ui::components::component_trait::{Component, ComponentResponse}; + + let mut selection_result: Option = None; + egui::Area::new(egui::Id::new("selection_dialog_modal").with(self.title.text())) + .fixed_pos(egui::Pos2::ZERO) + .order(egui::Order::Middle) + .interactable(true) + .show(ctx, |ui| { + ui.set_min_size(ctx.content_rect().size()); + let response = self.show(ui); + if let Some(status) = response.inner.changed_value() { + selection_result = Some(status.clone()); + } + }); + selection_result + } +} + +impl SelectionDialog { + /// Show the dialog and return the user's response + fn show_dialog(&mut self, ui: &mut Ui) -> InnerResponse> { + let mut is_open = self.is_open; + + if !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 = 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| { + ui.set_min_width(300.0); + + let dark_mode = ui.ctx().style().visuals.dark_mode; + + // Message + ui.add_space(10.0); + ui.label( + egui::RichText::new(self.message.text()) + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + ui.add_space(12.0); + + // ComboBox for option selection + if !self.options.is_empty() { + let selected_text = self + .options + .get(self.selected_index) + .map(|s| s.as_str()) + .unwrap_or_default(); + + let salt = ui.id().with("selection_dialog_combo"); + combo_popup_id = Some(ui.make_persistent_id(salt).with("popup")); + egui::ComboBox::from_id_salt(salt) + .selected_text(selected_text) + .width(ui.available_width() - 8.0) + .show_ui(ui, |ui| { + for (i, option) in self.options.iter().enumerate() { + ui.selectable_value(&mut self.selected_index, i, option); + } + }); + } + + ui.add_space(20.0); + + // Buttons (same layout as ConfirmationDialog) + ui.horizontal(|ui| { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + // Confirm button + if let Some(confirm_text) = &self.confirm_text { + let fill_color = ComponentStyles::primary_button_fill(); + let text_color = ComponentStyles::primary_button_text(); + + let confirm_label = if let WidgetText::RichText(rich_text) = + confirm_text + { + rich_text.clone() + } else { + Arc::new(egui::RichText::new(confirm_text.text()).color(text_color)) + }; + + let confirm_button = egui::Button::new(confirm_label) + .fill(fill_color) + .stroke(ComponentStyles::primary_button_stroke()) + .corner_radius(egui::CornerRadius::same(Shape::RADIUS_SM)) + .min_size(egui::Vec2::new(80.0, 32.0)); + + if ui + .add_enabled(!self.options.is_empty(), confirm_button) + .on_hover_cursor(egui::CursorIcon::PointingHand) + .clicked() + && !self.options.is_empty() + { + final_response = + Some(SelectionStatus::Selected(self.selected_index)); + } + } + + // Cancel button + if let Some(cancel_text) = &self.cancel_text { + let cancel_label = if let WidgetText::RichText(rich_text) = cancel_text + { + rich_text.clone() + } else { + egui::RichText::new(cancel_text.text()) + .color(ComponentStyles::secondary_button_text()) + .into() + }; + + let cancel_button = egui::Button::new(cancel_label) + .fill(ComponentStyles::secondary_button_fill()) + .stroke(ComponentStyles::secondary_button_stroke()) + .corner_radius(egui::CornerRadius::same(Shape::RADIUS_SM)) + .min_size(egui::Vec2::new(80.0, 32.0)); + + if ui + .add(cancel_button) + .on_hover_cursor(egui::CursorIcon::PointingHand) + .clicked() + { + final_response = Some(SelectionStatus::Canceled); + } + + ui.add_space(8.0); + } + }); + }); + }); + + // Handle window closed via X button + if !is_open && final_response.is_none() { + final_response = Some(SelectionStatus::Canceled); + } + + // Handle Escape key + if final_response.is_none() && ui.input(|i| i.key_pressed(egui::Key::Escape)) { + final_response = Some(SelectionStatus::Canceled); + } + + // Handle Enter key (skip if ComboBox dropdown is open) + let combo_open = combo_popup_id.is_some_and(|id| egui::Popup::is_id_open(ui.ctx(), id)); + if final_response.is_none() + && !self.options.is_empty() + && !combo_open + && ui.input(|i| i.key_pressed(egui::Key::Enter)) + { + final_response = Some(SelectionStatus::Selected(self.selected_index)); + } + + // Update state: record status before closing so current_value() sees it + if final_response.is_some() { + self.status = final_response.clone(); + self.is_open = false; + } else { + self.is_open = is_open; + } + + if let Some(window_response) = window_response { + InnerResponse::new(final_response, window_response.response) + } else { + InnerResponse::new( + final_response, + ui.allocate_response(egui::Vec2::ZERO, egui::Sense::hover()), + ) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_selection_dialog_creation() { + let dialog = SelectionDialog::new( + "Pick Wallet", + "Choose the wallet to use", + vec!["Wallet A".into(), "Wallet B".into(), "Wallet C".into()], + ) + .confirm_text(Some("Use")) + .cancel_text(Some("Back")) + .preselect(1); + + assert_eq!(dialog.title.text(), "Pick Wallet"); + assert_eq!(dialog.message.text(), "Choose the wallet to use"); + assert_eq!(dialog.options.len(), 3); + assert_eq!(dialog.selected_index, 1); + assert!(dialog.confirm_text.is_some_and(|t| t.text() == "Use")); + assert!(dialog.cancel_text.is_some_and(|t| t.text() == "Back")); + assert!(dialog.is_open); + } + + #[test] + fn test_selection_dialog_default_buttons() { + let dialog = SelectionDialog::new("Title", "Message", vec!["A".into(), "B".into()]); + + assert!(dialog.confirm_text.is_some_and(|t| t.text() == "Select")); + assert!(dialog.cancel_text.is_some_and(|t| t.text() == "Cancel")); + assert_eq!(dialog.selected_index, 0); + } + + #[test] + fn test_selection_dialog_preselect() { + // Normal preselection + let dialog = + SelectionDialog::new("Title", "Message", vec!["A".into(), "B".into(), "C".into()]) + .preselect(2); + assert_eq!(dialog.selected_index, 2); + + // Out-of-bounds clamped to last index + let dialog = + SelectionDialog::new("Title", "Message", vec!["A".into(), "B".into()]).preselect(99); + assert_eq!(dialog.selected_index, 1); + + // Empty options: stays at 0 + let dialog = SelectionDialog::new("Title", "Message", vec![]).preselect(5); + assert_eq!(dialog.selected_index, 0); + } + + #[test] + fn test_selection_dialog_no_buttons() { + let dialog = SelectionDialog::new("Title", "Message", vec!["Only".into()]) + .confirm_text(NOTHING) + .cancel_text(NOTHING); + + assert!(dialog.confirm_text.is_none()); + assert!(dialog.cancel_text.is_none()); + } +} diff --git a/src/ui/components/styled.rs b/src/ui/components/styled.rs index a4821c763..137e2625a 100644 --- a/src/ui/components/styled.rs +++ b/src/ui/components/styled.rs @@ -11,6 +11,7 @@ use egui::{ // 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)] 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 index d6b593760..d2c5fbdcb 100644 --- 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 @@ -1,5 +1,6 @@ use crate::app::AppAction; use crate::backend_task::BackendTask; +use crate::backend_task::error::TaskError; use crate::backend_task::identity::{ IdentityRegistrationInfo, IdentityTask, RegisterIdentityFundingMethod, }; @@ -9,13 +10,12 @@ 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 dash_sdk::dashcore_rpc::RpcApi; 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<(), String> { + 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`. @@ -28,42 +28,24 @@ impl AddNewIdentityScreen { true, Some(&self.app_context), )?; + 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 - .core_client - .read() - .expect("Core client lock was poisoned") - .import_address( - &receive_address, - Some("Managed by Dash Evo Tool"), - Some(false), - ) - .map_err(|e| e.to_string())?; + 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 { - let info = self - .app_context - .core_client - .read() - .expect("Core client lock was poisoned") - .get_address_info(&receive_address) - .map_err(|e| e.to_string())?; - - if !(info.is_watchonly || info.is_mine) { - self.app_context - .core_client - .read() - .expect("Core client lock was poisoned") - .import_address( - &receive_address, - Some("Managed by Dash Evo Tool"), - Some(false), - ) - .map_err(|e| e.to_string())?; - } + 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); } @@ -74,7 +56,7 @@ impl AddNewIdentityScreen { (self.funding_address.as_ref().unwrap().clone(), false) } } else { - return Err("No wallet selected".to_string()); + return Err("No wallet selected".to_string().into()); } }; @@ -167,7 +149,11 @@ impl AddNewIdentityScreen { 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, MessageType::Error); + MessageBanner::set_global( + ui.ctx(), + e.to_string(), + MessageType::Error, + ); } ui.add_space(20.0); 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 index 4c916473f..01ff05f79 100644 --- 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 @@ -1,17 +1,17 @@ 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 dash_sdk::dashcore_rpc::RpcApi; 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<(), String> { + 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 @@ -22,29 +22,14 @@ impl TopUpIdentityScreen { true, Some(&self.app_context), )?; + let core_wallet_name = wallet.core_wallet_name.clone(); + drop(wallet); - // Import address to Core if needed for monitoring - let core_client = self - .app_context - .core_client - .read() - .map_err(|_| "Core client lock was poisoned".to_string())?; - - let info = core_client - .get_address_info(&receive_address) - .map_err(|e| e.to_string())?; - - if !(info.is_watchonly || info.is_mine) { - core_client - .import_address( - &receive_address, - Some("Managed by Dash Evo Tool"), - Some(false), - ) - .map_err(|e| e.to_string())?; - } - - drop(core_client); + 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 @@ -52,7 +37,7 @@ impl TopUpIdentityScreen { self.funding_address.as_ref().unwrap().clone() } } else { - return Err("No wallet selected".to_string()); + return Err("No wallet selected".to_string().into()); } }; @@ -128,7 +113,12 @@ impl TopUpIdentityScreen { if let Ok(amount_dash) = self.funding_amount.parse::() { if amount_dash > 0.0 { if let Err(e) = self.render_qr_code(ui, amount_dash) { - MessageBanner::set_global(ui.ctx(), &e, MessageType::Error); + 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"); diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 08e312893..c93d97426 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,5 +1,6 @@ use crate::app::AppAction; use crate::backend_task::BackendTaskSuccessResult; +use crate::backend_task::error::TaskError; use crate::context::AppContext; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::qualified_identity::encrypted_key_storage::{ @@ -856,6 +857,14 @@ pub trait ScreenLike { /// override this for their expected result variants. fn display_task_result(&mut self, _backend_task_success_result: BackendTaskSuccessResult) {} + /// Called by `AppState` when a backend task fails with a typed error. + /// + /// Override to handle specific error variants (e.g., `CoreWalletNotConfigured`). + /// Return `true` to suppress the default error banner in `AppState`. + fn display_task_error(&mut self, _error: &TaskError) -> bool { + false + } + fn pop_on_success(&mut self) {} } @@ -1505,6 +1514,72 @@ impl ScreenLike for Screen { } } + 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), + } + } + fn pop_on_success(&mut self) { match self { Screen::IdentitiesScreen(screen) => screen.pop_on_success(), diff --git a/src/ui/wallets/add_new_wallet_screen.rs b/src/ui/wallets/add_new_wallet_screen.rs index f25a320d6..07f6fa18b 100644 --- a/src/ui/wallets/add_new_wallet_screen.rs +++ b/src/ui/wallets/add_new_wallet_screen.rs @@ -101,6 +101,10 @@ pub struct AddNewWalletScreen { receive_qr_texture: Option, show_receive_popup: bool, funds_received: bool, + /// Cached list of Core wallets (fetched once on screen init) + core_wallets: Option>, + /// Index of selected Core wallet in the ComboBox + selected_core_wallet_index: usize, } impl AddNewWalletScreen { @@ -125,6 +129,10 @@ impl AddNewWalletScreen { receive_qr_texture: None, show_receive_popup: false, funds_received: false, + // TODO(CMT-007): Move list_core_wallets() off the UI thread — synchronous RPC + // can block screen construction. Fetch via backend task or cache. + core_wallets: app_context.list_core_wallets().ok(), + selected_core_wallet_index: 0, } } @@ -269,6 +277,10 @@ impl AddNewWalletScreen { unconfirmed_balance: 0, total_balance: 0, platform_address_info: Default::default(), + core_wallet_name: self + .core_wallets + .as_ref() + .and_then(|ws| ws.get(self.selected_core_wallet_index).cloned()), }; self.app_context @@ -858,7 +870,43 @@ impl ScreenLike for AddNewWalletScreen { ui.separator(); ui.add_space(10.0); - ui.heading("6. Save the wallet."); + 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.add_space(10.0); // Save Wallet button styled like Load Identity button diff --git a/src/ui/wallets/create_asset_lock_screen.rs b/src/ui/wallets/create_asset_lock_screen.rs index 4f6b3aaba..621cde127 100644 --- a/src/ui/wallets/create_asset_lock_screen.rs +++ b/src/ui/wallets/create_asset_lock_screen.rs @@ -1,5 +1,6 @@ use crate::app::AppAction; use crate::backend_task::core::{CoreItem, CoreTask}; +use crate::backend_task::error::TaskError; use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; use crate::context::AppContext; use crate::model::amount::Amount; @@ -16,7 +17,6 @@ use crate::ui::components::wallet_unlock::ScreenWithWalletUnlock; use crate::ui::identities::funding_common::{self, WalletFundedScreenStep, generate_qr_code_image}; use crate::ui::theme::DashColors; use crate::ui::{MessageType, RootScreenType, ScreenLike}; -use dash_sdk::dashcore_rpc::RpcApi; use dash_sdk::dashcore_rpc::dashcore::{Address, OutPoint, TxOut}; use eframe::egui::{self, Context, Ui}; use egui::{Button, RichText, Vec2}; @@ -97,49 +97,31 @@ impl CreateAssetLockScreen { } } - fn generate_funding_address(&mut self) -> Result<(), String> { + 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))?; + 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 - .core_client - .read() - .expect("Core client lock was poisoned") - .import_address( - &receive_address, - Some("Managed by Dash Evo Tool - Asset Lock"), - Some(false), - ) - .map_err(|e| e.to_string())?; + 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 { - let info = self - .app_context - .core_client - .read() - .expect("Core client lock was poisoned") - .get_address_info(&receive_address) - .map_err(|e| e.to_string())?; - - if !(info.is_watchonly || info.is_mine) { - self.app_context - .core_client - .read() - .expect("Core client lock was poisoned") - .import_address( - &receive_address, - Some("Managed by Dash Evo Tool - Asset Lock"), - Some(false), - ) - .map_err(|e| e.to_string())?; - } + 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); } @@ -147,7 +129,7 @@ impl CreateAssetLockScreen { Ok(()) } - fn render_qr_code(&mut self, ui: &mut egui::Ui) -> Result<(), String> { + fn render_qr_code(&mut self, ui: &mut egui::Ui) -> Result<(), TaskError> { if self.funding_address.is_none() { self.generate_funding_address()? } @@ -586,7 +568,12 @@ impl ScreenLike for CreateAssetLockScreen { 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(), &e, MessageType::Error); + MessageBanner::set_global( + ui.ctx(), + "Failed to render QR code", + MessageType::Error, + ) + .with_details(e); } ui.add_space(20.0); diff --git a/src/ui/wallets/import_mnemonic_screen.rs b/src/ui/wallets/import_mnemonic_screen.rs index 85247628a..1cdbe5c9e 100644 --- a/src/ui/wallets/import_mnemonic_screen.rs +++ b/src/ui/wallets/import_mnemonic_screen.rs @@ -55,6 +55,11 @@ pub struct ImportMnemonicScreen { // Identity discovery options identity_scan_count: u32, + + /// Cached list of Core wallets (fetched once on screen init) + core_wallets: Option>, + /// Index of selected Core wallet in the ComboBox + selected_core_wallet_index: usize, } impl ImportMnemonicScreen { @@ -83,6 +88,11 @@ impl ImportMnemonicScreen { // Identity discovery options identity_scan_count: 5, + + // TODO(CMT-008): Move list_core_wallets() off the UI thread — synchronous RPC + // can block screen construction. Fetch via backend task or cache. + core_wallets: app_context.list_core_wallets().ok(), + selected_core_wallet_index: 0, } } @@ -137,9 +147,15 @@ impl ImportMnemonicScreen { }; // Try WIF first, then hex - let wallet = SingleKeyWallet::from_wif(input, password, alias.clone()).or_else(|_| { - SingleKeyWallet::from_hex(input, self.app_context.network, password, alias) - })?; + 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(); @@ -165,6 +181,7 @@ impl ImportMnemonicScreen { self.wallet_imported = true; Ok(AppAction::None) } + fn save_wallet(&mut self) -> Result { if let Some(mnemonic) = &self.seed_phrase { let seed = mnemonic.to_seed(""); @@ -244,6 +261,10 @@ impl ImportMnemonicScreen { unconfirmed_balance: 0, total_balance: 0, platform_address_info: Default::default(), + core_wallet_name: self + .core_wallets + .as_ref() + .and_then(|ws| ws.get(self.selected_core_wallet_index).cloned()), }; self.app_context @@ -707,6 +728,40 @@ impl ScreenLike for ImportMnemonicScreen { 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/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index 0227e97c4..bcc4bb655 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -6,6 +6,7 @@ mod single_key_view; use crate::app::{AppAction, DesiredAppAction}; use crate::backend_task::BackendTask; use crate::backend_task::core::CoreTask; +use crate::backend_task::error::TaskError; use crate::context::AppContext; use crate::context::connection_status::spv_phase_summary; use crate::model::amount::Amount; @@ -14,6 +15,7 @@ use crate::spv::{CoreBackendMode, SpvStatus}; 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::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::wallet_unlock_popup::{WalletUnlockPopup, WalletUnlockResult}; @@ -108,6 +110,14 @@ pub struct WalletsBalancesScreen { refresh_mode: RefreshMode, /// 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, + /// 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>, + /// Whether the pending Core wallet selection is for a single-key wallet + pending_core_wallet_is_single_key: bool, } impl WalletsBalancesScreen { @@ -202,6 +212,10 @@ impl WalletsBalancesScreen { utxo_page: 0, refresh_mode: RefreshMode::default(), 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, } } @@ -225,6 +239,60 @@ impl WalletsBalancesScreen { .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 @@ -1937,6 +2005,58 @@ impl ScreenLike for WalletsBalancesScreen { } } + // 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, + ); + } + } + } + // Combine with pending refresh action action |= pending_refresh_action; action @@ -1944,8 +2064,10 @@ impl ScreenLike for WalletsBalancesScreen { fn display_message(&mut self, message: &str, message_type: MessageType) { // Banner display is handled globally by AppState; this is only for side-effects. + // Always clear refreshing — the originating task is done regardless of result type. + self.refreshing = false; + if matches!(message_type, MessageType::Error | MessageType::Warning) { - self.refreshing = false; self.asset_lock_search_banner.take_and_clear(); // If the fund platform dialog is processing, show error in the dialog instead @@ -1957,6 +2079,90 @@ impl ScreenLike for WalletsBalancesScreen { } } + /// Intercept Core-wallet-not-configured errors and show the wallet selection dialog. + 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) + }; + + // TODO(CMT-004): Move list_core_wallets() off the UI thread — synchronous RPC + // can block rendering if Core is slow/unreachable. Fetch via backend task instead. + match self.app_context.list_core_wallets() { + Ok(wallets) 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); + } + } + } + } + Ok(wallets) 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; + } + Ok(_) => { + MessageBanner::set_global( + self.app_context.egui_ctx(), + "No wallets loaded in Dash Core", + MessageType::Error, + ); + } + Err(e) => { + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Failed to list Dash Core wallets", + MessageType::Error, + ) + .with_details(e); + } + } + true // Suppress generic error banner + } else { + false + } + } + fn display_task_result( &mut self, backend_task_success_result: crate::ui::BackendTaskSuccessResult, @@ -2156,5 +2362,8 @@ impl ScreenLike for WalletsBalancesScreen { } } - fn refresh(&mut self) {} + fn refresh(&mut self) { + self.refreshing = false; + self.refresh_on_arrival(); + } }