diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c33f9bd9..8aafa870d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,25 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). does not support shielded sending from when the current interface mode does not unlock it. +- **Identity Home actions simplified**: the action row previously had six + buttons — several of which opened the same screen (`Send`/`Send to another + identity`, `Receive`/`Add funds`). It's now one row of four: **Add funds**, + **Send to wallet**, **Send to another identity**, and **Add contact**. + **Send to wallet** is disabled with an explanation when the identity has no + withdrawal key loaded. The **Add funds** screen, and its wording + throughout, now consistently says "Add funds" instead of "Top Up Identity", + and its step-by-step deposit messages no longer use technical wording. + +- **Fewer native crashes while verifying a deposit**: background worker + threads now get a larger stack, fixing a crash that could occur during + deposit verification. + +- **Clearer guidance for an already-used deposit**: registering an identity, + topping one up, or funding a platform address with a deposit that was + already consumed by another operation now tells you directly to choose a + different deposit or start a new one, instead of the generic rejection + message that suggested retrying the same one. + ### Changed - **Upstream wallet backend updated (`platform-wallet` / `platform-wallet-storage`)**: diff --git a/docs/ai-design/2026-07-24-identity-home-actions/README.md b/docs/ai-design/2026-07-24-identity-home-actions/README.md new file mode 100644 index 000000000..39638ee63 --- /dev/null +++ b/docs/ai-design/2026-07-24-identity-home-actions/README.md @@ -0,0 +1,220 @@ +# Identity Home — action set redesign (2026-07-24) + +Design record for collapsing the Identity Home tab's two action rows (6 buttons, +4 destinations) into one non-redundant row of 4. Read-only analysis; no code +changed by this note. + +Source of truth reviewed: `src/ui/identity/home.rs`, +`src/ui/identities/{top_up_identity_screen,transfer_screen,withdraw_screen}`, +`docs/personas/everyday-user.md`, `docs/user-stories.md`, +`docs/ux-design-patterns.md`, `docs/ai-design/2026-04-22-identity-dashpay-redesign/design-spec.md` §B.2/§B.7/§B.9. + +## 1. What is actually there today + +`home_button_kind()` (home.rs:174) maps six rendered buttons onto four screens: + +| Rendered button | Style | Opens | +|---|---|---| +| `Send` | primary blue | `TransferScreen` | +| `Receive` | primary blue | `TopUpIdentityScreen` | +| `Add contact` | outlined | Contacts tab (gated on social profile) | +| `Add funds` | ghost | `TopUpIdentityScreen` — same as `Receive` | +| `Send to wallet` | ghost | `WithdrawalScreen` — the only unique entry in its row | +| `Send to another identity` | ghost | `TransferScreen` — same as `Send` | + +Two exact duplicate pairs, and the pairs are rendered at *different visual +weights*, so the same destination is simultaneously advertised as the most and +the least important thing on the screen. + +### Why the duplication exists + +Design-spec §B.2 specified the quick-actions row as **payment** affordances: + +- `Send` → "Send Dash to a contact, username, or address." — the §B.7 Send sheet + (recipient field with DPNS lookup). **Not implemented.** The only payment path + that exists is per-contact (`Contacts` → row → `Pay` → `DashPaySendPayment`, + which requires a `to_contact_id` and so cannot serve a recipient-less entry + point). +- `Receive` → "Show a QR code or your username so someone can pay you." — a + receive view. **Not implemented, and not implementable as specified**: an + identity cannot be paid directly. Funding is always self-initiated from your + own wallet via an asset lock (IDN-004). The nearest real capability, IDN-014 + "Receive a new deposit", derives a **wallet** address, so the funds land in + the wallet and the user still has to finish the top-up wizard. + +Rather than mark those two as gaps, the implementation re-pointed them at the +funding screens and rewrote the tooltips to describe the new behaviour (the +`T30` comments at home.rs:335 and :348). The secondary row then landed the +*correctly labelled* funding actions on top, producing the duplication. + +## 2. Jobs to be done from this screen + +An identity is an account whose balance is fuel for Platform operations. From +Home, its owner wants exactly four things: + +1. **Put value in** — "I want to keep using Platform features." → `TopUp` + (from wallet balance, a Platform address, an existing funding transaction, or + a fresh deposit — all four are methods *inside* the one wizard). +2. **Take value out to Dash I can spend** — → `Withdrawal` (queued on Platform, + settles to a Core address). +3. **Move value to another Platform participant** — → `Transfer` (destination + toggle: another identity, or a Platform address). +4. **Connect with people** — → Contacts tab. + +There is no fifth job. "Receive" is not a job an identity can do. + +Frequency, for the Everyday User (Alex): fees are consumed continuously, so +topping up is the recurring action; withdrawing is rare but emotionally +load-bearing ("can I get my money back?"); identity→identity transfer is the +rarest. Hierarchy follows that ordering. + +## 3. Recommended action set + +One row, four buttons, four destinations — read left to right as money in → +money out → social. + +| # | Label | Style | Destination | Tooltip | +|---|---|---|---|---| +| 1 | `Add funds` | Primary | `HomeScreenKind::TopUp` | `Move Dash from your wallet into this identity.` | +| 2 | `Send to wallet` | Secondary | `HomeScreenKind::Withdrawal` | `Move Dash out of this identity to a Dash address, such as one from your wallet.` | +| 3 | `Send to another identity` | Secondary | `HomeScreenKind::Transfer` | `Send Dash from this identity to another identity. You can also send to a Platform address.` | +| 4 | `Add contact` | Secondary | Contacts tab | unchanged (enabled + disabled copy both stay as-is) | + +``` +[ Add funds ] [ Send to wallet ] [ Send to another identity ] [ Add contact ] + primary secondary secondary secondary + money in money out (L1) money out (Platform) social +``` + +- `Send` and `Receive` are **removed**, not renamed. Their `HomeButton` variants + (`Send`, `Receive`) go with them. +- One primary only. Two blue buttons implied two equally-weighted entry points + where in fact one was a duplicate of a ghost button. +- Use `StyledButton` / `ComponentStyles` (ux-design-patterns §3) rather than the + ad-hoc `egui::Button` builders `primary_quick_action` / `ghost_action`; that + also resolves the 40 px vs 36 px height mismatch between the current rows. +- Render with `ui.horizontal_wrapped` so four ~150 px buttons wrap instead of + clipping on a narrow window. + +### Is a bare `Send` still meaningful? + +Not today. With `Send to wallet` and `Send to another identity` both present and +honestly labelled, a third bare `Send` answers neither "send what" nor "send +where", and its two candidate meanings live on two different screens with +different mechanics (a queued Core withdrawal vs an instant Platform transfer). + +A bare `Send` becomes correct only under one condition: a single screen that +accepts any destination from an identity source. That screen already exists — +`WalletSendScreen` supports `SourceSelection::Identity` with `AddressKind::Core` +("Withdraw Credits"), `::Platform` ("Transfer to Address") and `::Identity` +("Transfer Credits"), auto-detecting the kind from what the user pastes. It +lacks only an identity-source preset (`WalletSendScreen::new` hard-codes +`SourceSelection::CoreWallet` and takes a `Wallet`). When that preset lands, +rows 2 and 3 collapse into one `Send` — `Send Dash from this identity. Paste a +Dash address, a Platform address, or an identity.` That is a follow-up, not part +of this fix. + +### What replaces "Receive" + +Nothing, at identity level. The deposit-QR capability stays where it already is +and works: the `Receive a new deposit` method inside the Add funds wizard, which +is always offered and needs no pre-existing balance. Surfacing it as a top-level +`Receive` would teach the wrong model — that people can pay an identity +directly — and would collide with the Wallet screen's `Receive`, which opens an +address + QR dialog. The redesign spec already reached the same conclusion for +the empty-wallet banner in §B.1: *"this app has no separate top-level Receive +screen, so the link goes to Wallets, where the user's receiving address lives."* + +## 4. Persona walk-through of the proposed row + +- **Alex (everyday).** Lands on Home, sees one blue button that adds money and + three quiet ones. Every label names a destination he recognises (his wallet, + someone else, a person). No word appears twice; no word means two things. +- **Priya (power).** Loses nothing: Platform-address transfer is still one click + away inside `Transfer`, and the tooltip now says so, which the old `Send` + tooltip did not. +- **Jordan (developer).** Unaffected — no developer-gated affordance was in + either row. + +## 5. Regression guard + +Replace the unit test `primary_send_receive_mappings_are_stable`, which +currently pins `Send → Transfer` *and* `SendToAnotherIdentity → Transfer` as +intended behaviour, with an injectivity assertion over the action-row buttons: +no two of them may resolve to the same `HomeScreenKind`. (Scope it to the action +row — `PickUsernameHero` and `ChecklistPickUsername` legitimately share +`RegisterDpnsName`, in two different contexts that the module already takes care +never to show at the same time; see the `checklist_covers_profile` suppression +at home.rs:323.) `ALL_HOME_BUTTONS` and `all_buttons_list_is_exhaustive` need +the two removed variants dropped. + +## 6. Adjacent debt found while reviewing — folded into this same fix + +Scope was widened (2026-07-24) to fix all of the following in the same change, +not defer them: + +1. `Send to wallet` over-promises: `WithdrawalScreen` offers a blank + `Address:` field and validates a hand-typed Core address. There is no "use an + address from my wallet" affordance, so the user must fetch one from the + Wallets screen themselves. Either add that affordance or soften the tooltip + (done above) to not claim it. +2. Dead end for identities without a local TRANSFER/OWNER key: + `WithdrawalScreen` renders a dark-red *"You do not have any withdrawal keys + loaded for this {type} identity. Note that TRANSFER or OWNER keys are used + for withdrawals."* Home does not gate the button, and the message is not + written for the Everyday User. `identity.available_withdrawal_keys()` is + available at Home — gate `Send to wallet` the same way `Add contact` is + gated (disabled + `disabled_tooltip` explaining why). +3. PR #869 jargon leftovers in `top_up_identity_screen`: + `WALLET_SELECTION_TOOLTIP` ("create the asset lock transaction", + mod.rs:46), `"=> Waiting for Core Chain to produce proof of transfer of + funds. <="` and `"=> Waiting for Platform acknowledgement <="` + (by_receive_deposit.rs:187/190, by_using_unused_balance.rs:156/160, + by_using_unused_asset_lock.rs:117), and `"Wallet Balance: {:.8} DASH"` + (by_using_unused_balance.rs:28). Rewrite per the error-messages / i18n-ready + string rules in CLAUDE.md. + + **Correction (QA finding, 2026-07-24):** this item originally claimed the + rewrite would match "the sibling fix already applied to + `add_new_identity_screen` in PR #869." That claim is false — independently + verified that `add_new_identity_screen` (plus `wallets/create_asset_lock_screen.rs` + and `dashpay/send_payment.rs`) still contain the identical unfixed jargon + today. PR #869 touched those files but did not fix these specific strings + (or they regressed since). The rewrite in `top_up_identity_screen` proceeded + on its own merits against the CLAUDE.md rules directly, not by mirroring + another screen. See §7 for the resulting follow-up. +4. Label→title discontinuity: `Add funds` opens a screen whose breadcrumb and + heading say *Top Up Identity*. Align the screen's visible title/breadcrumb + with the button label (pick one direction and make both match). +5. `docs/user-stories.md` uses the ID `IDN-013` for two different stories + (identity key protection, and top up from Platform addresses). Renumber one. +6. §B.2's `Send` (payment sheet) and `Receive` (payment QR) are unimplemented + but are not recorded as `[Gap]` stories anywhere. Add `[Gap]` entries so the + catalog reflects that these were designed but never built, distinct from the + redesigned action row documented here. + +## 7. Implementation status and follow-ups + +Items 1–6 above (and §3/§5) landed in commit `398ee3f4`. QA (independent +adversarial pass) caught two problems in the first implementation attempt, +both since fixed and re-verified: a kittest regression in +`withdraw_screen.rs` (two tests substring-matched the old dead-end message +text — updated to match the new wording without weakening what they guard), +and item 4 initially renaming only the breadcrumb while every heading and CTA +button downstream still said "Top Up Identity" (now fully aligned to "Add +funds"/"Add Funds" throughout). + +Two items QA found are explicitly **not** part of this fix, left for a +separate follow-up: + +- The jargon in `add_new_identity_screen`, `wallets/create_asset_lock_screen.rs`, + and `dashpay/send_payment.rs` (see the correction in item 3 above) — same + category of fix, different screens, kept out to avoid scope creep on an + already-large change. +- Withdrawal-key gating is now checked three different ways across three + surfaces: Home's `Send to wallet` button (this fix, `available_withdrawal_keys().is_empty()`), + the `identities_screen.rs` "Withdraw" popup action (gated on balance only, + no key check), and `WithdrawalScreen`'s own internal check (role-aware — + Developer-role users need only *any* public key, not specifically + TRANSFER/OWNER). Not a dead end in practice, just an inconsistency across + entry points. Not requested by this fix's scope, not fixed here. diff --git a/docs/user-stories.md b/docs/user-stories.md index 1d89475eb..72be13402 100644 --- a/docs/user-stories.md +++ b/docs/user-stories.md @@ -543,6 +543,24 @@ As a user, I want to transfer credits from one identity to another so that I can - Select source and destination identities. - Enter transfer amount. +### IDN-018: Start an identity payment without choosing a contact first [Gap] +**Persona:** Alex + +As an everyday user, I want to start a payment from my identity before choosing a recipient so that I can send Dash without first opening a contact. + +- The Identity Home screen offers the recipient-less Send payment sheet specified in design-spec §B.2 and §B.7. +- The payment sheet lets the user choose a contact, username, or supported address. +- The existing transfer screen does not satisfy this story because it only moves funds to another identity or a Platform address. + +### IDN-019: Receive Dash directly into an identity [Gap] +**Persona:** Alex + +As an everyday user, I want to open a Receive view for my identity so that another person can scan a QR code or use an address to pay me. + +- Design-spec §B.2 and tooltip catalog §D #72–76 specify an identity Receive view that shares a username and a one-time Dash address or QR code. +- An identity cannot receive Dash directly and has no receiving address, so this flow is not buildable as specified. +- Funding must start from a wallet: Dash arrives in the wallet first, then the user adds those funds to the identity. + ### IDN-007: Add key to identity [Implemented] **Persona:** Priya, Jordan @@ -605,7 +623,7 @@ As a user, I want to register a new identity using Platform address credits so t - Alternative funding method in identity registration wizard. - Uses existing Platform address balance. -### IDN-013: Top up identity from Platform addresses [Implemented] +### IDN-017: Top up identity from Platform addresses [Implemented] **Persona:** Priya, Jordan As a user, I want to top up identity credits from a Platform address so that I can fund identities from my Platform balance. diff --git a/src/backend_task/error.rs b/src/backend_task/error.rs index 474c83b6c..812a80426 100644 --- a/src/backend_task/error.rs +++ b/src/backend_task/error.rs @@ -1563,6 +1563,15 @@ pub enum TaskError { source_error: Box, }, + /// The funding transaction output has already been used for another operation. + #[error( + "This deposit has already been used and cannot be used again. Choose a different deposit, or start a new deposit." + )] + AssetLockOutPointAlreadyConsumed { + #[source] + source_error: Box, + }, + /// Fetching address information from the platform failed. #[error("Could not retrieve address information from the platform. Please retry.")] PlatformFetchError { @@ -2920,6 +2929,11 @@ impl From for TaskError { } })) } + ConsensusError::BasicError( + BasicError::IdentityAssetLockTransactionOutPointAlreadyConsumedError(_), + ) => Some(Box::new(|source_error| { + TaskError::AssetLockOutPointAlreadyConsumed { source_error } + })), ConsensusError::StateError(StateError::InsufficientPoolNotesError(e)) => { let (current_count, minimum_required) = (e.current_count(), e.minimum_required()); @@ -4341,6 +4355,44 @@ mod tests { ); } + #[test] + fn from_sdk_error_asset_lock_outpoint_already_consumed_via_consensus() { + use dash_sdk::dpp::consensus::basic::identity::IdentityAssetLockTransactionOutPointAlreadyConsumedError; + use dashcore::hashes::Hash; + let consensus = ConsensusError::from( + IdentityAssetLockTransactionOutPointAlreadyConsumedError::new( + dashcore::Txid::from_byte_array([0u8; 32]), + 0, + ), + ); + let err = TaskError::from(SdkError::from(consensus)); + assert!( + matches!(err, TaskError::AssetLockOutPointAlreadyConsumed { .. }), + "Expected AssetLockOutPointAlreadyConsumed, got: {err:?}" + ); + } + + #[test] + fn asset_lock_outpoint_already_consumed_display_is_actionable_and_non_retryable() { + use dash_sdk::dpp::consensus::basic::identity::IdentityAssetLockTransactionOutPointAlreadyConsumedError; + use dashcore::hashes::Hash; + let consensus = ConsensusError::from( + IdentityAssetLockTransactionOutPointAlreadyConsumedError::new( + dashcore::Txid::from_byte_array([0u8; 32]), + 0, + ), + ); + let message = TaskError::from(SdkError::from(consensus)).to_string(); + assert!( + message.contains("different deposit") && message.contains("new deposit"), + "Expected an alternative funding action, got: {message}" + ); + assert!( + !message.to_lowercase().contains("retry"), + "A permanently consumed deposit must not suggest retrying: {message}" + ); + } + #[test] fn from_sdk_error_asset_lock_outpoint_insufficient_balance_via_broadcast() { use dash_sdk::dpp::consensus::basic::identity::IdentityAssetLockTransactionOutPointNotEnoughBalanceError; diff --git a/src/bin/det_cli/connect.rs b/src/bin/det_cli/connect.rs index 3824f953f..1a7201148 100644 --- a/src/bin/det_cli/connect.rs +++ b/src/bin/det_cli/connect.rs @@ -1,3 +1,4 @@ +use dash_evo_tool::context::SDK_THREAD_STACK_SIZE; use rmcp::ServiceExt; use super::McpClient; @@ -30,6 +31,7 @@ pub(super) fn run_stdio_server() -> ! { let runtime = tokio::runtime::Builder::new_multi_thread() .worker_threads(4) + .thread_stack_size(SDK_THREAD_STACK_SIZE) // 4 MiB stack size for each worker thread .enable_all() .build() .expect("failed to build Tokio runtime"); diff --git a/src/bin/det_cli/headless.rs b/src/bin/det_cli/headless.rs index 5deb40b8b..d491f2286 100644 --- a/src/bin/det_cli/headless.rs +++ b/src/bin/det_cli/headless.rs @@ -7,6 +7,8 @@ /// runtime teardown to prevent coordinator OS threads from panicking against a /// shutting-down timer wheel. See `DashMcpService::shutdown_wallet_backend` /// for the race analysis. +use dash_evo_tool::context::SDK_THREAD_STACK_SIZE; + pub(super) fn run_headless() -> Result<(), Box> { use dash_evo_tool::logging::initialize_logger; use dash_evo_tool::mcp::server::{init_app_context, shutdown_app_context_wallet_backend}; @@ -25,6 +27,7 @@ pub(super) fn run_headless() -> Result<(), Box> { let runtime = tokio::runtime::Builder::new_multi_thread() .worker_threads(4) + .thread_stack_size(SDK_THREAD_STACK_SIZE) // 4 MiB stack size for each worker thread .enable_all() .build()?; diff --git a/src/bin/det_cli/main.rs b/src/bin/det_cli/main.rs index 4014a5685..4da161dba 100644 --- a/src/bin/det_cli/main.rs +++ b/src/bin/det_cli/main.rs @@ -4,6 +4,7 @@ //! Mode is selected automatically: HTTP when MCP_API_KEY is set, in-process otherwise. use clap::{Parser, Subcommand}; +use dash_evo_tool::context::SDK_THREAD_STACK_SIZE; use rmcp::RoleClient; use rmcp::model::CallToolRequestParams; use rmcp::service::RunningService; @@ -123,6 +124,7 @@ fn main() -> Result<(), Box> { let runtime = tokio::runtime::Builder::new_multi_thread() .worker_threads(2) + .thread_stack_size(SDK_THREAD_STACK_SIZE) // 4 MiB stack size for each worker thread .enable_all() .build()?; diff --git a/src/context/mod.rs b/src/context/mod.rs index 05c947bab..002662e90 100644 --- a/src/context/mod.rs +++ b/src/context/mod.rs @@ -57,6 +57,7 @@ use crate::model::settings::AppSettings; use crate::model::user_role::{UserRole, UserRoleCell}; const ANIMATION_REFRESH_TIME: std::time::Duration = std::time::Duration::from_millis(100); +pub const SDK_THREAD_STACK_SIZE: usize = 4 * 1024 * 1024; // 4 MB stack size for each worker thread /// A guard that ensures settings cache invalidation happens atomically /// diff --git a/src/main.rs b/src/main.rs index 706fdc618..6b5f6937e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ #![cfg_attr(target_os = "windows", windows_subsystem = "windows")] +use dash_evo_tool::context::SDK_THREAD_STACK_SIZE; use dash_evo_tool::*; use crate::boot::prepare_environment; @@ -28,6 +29,7 @@ fn main() -> eframe::Result<()> { // Initialize the Tokio runtime let runtime = tokio::runtime::Builder::new_multi_thread() .worker_threads(12) + .thread_stack_size(SDK_THREAD_STACK_SIZE) // 4 MiB stack size for each worker thread // Each worker/blocking thread needs its own alternate signal stack. .on_thread_start(install_alt_signal_stack) .enable_all() diff --git a/src/model/qualified_identity/mod.rs b/src/model/qualified_identity/mod.rs index 5fd6d2c45..5a3d2a2b6 100644 --- a/src/model/qualified_identity/mod.rs +++ b/src/model/qualified_identity/mod.rs @@ -9,6 +9,7 @@ pub mod qualified_identity_public_key; use crate::backend_task::error::TaskError; use crate::model::qualified_identity::encrypted_key_storage::{KeyStorage, ResolvedPrivateKey}; use crate::model::qualified_identity::qualified_identity_public_key::QualifiedIdentityPublicKey; +use crate::model::user_role::UserRole; use crate::model::wallet::{Wallet, WalletSeedHash}; use bincode::{Decode, Encode}; use dash_sdk::dashcore_rpc::dashcore::{PubkeyHash, signer}; @@ -886,6 +887,23 @@ impl QualifiedIdentity { keys } + /// Whether the active role can attempt a withdrawal with this identity. + /// + /// Uniform across every role: a withdrawal is only ever executable with a + /// locally-held TRANSFER or OWNER key (see + /// [`resolve_withdrawal_signing_key`](Self::resolve_withdrawal_signing_key), + /// the backend's unconditional enforcement layer — no role currently + /// relaxes it). A Developer-only "any on-chain key" carve-out previously + /// existed here and in [`default_withdrawal_key`](Self::default_withdrawal_key)'s + /// caller, but nothing downstream ever accepted an on-chain-only key for + /// signing, so that carve-out only produced an enabled button that led to + /// a blank or failing withdrawal screen. Gate on the same invariant the + /// backend enforces, so this predicate never lies about what the screen + /// can actually do. + pub fn can_attempt_withdrawal(&self, _role: UserRole) -> bool { + !self.available_withdrawal_keys().is_empty() + } + /// Returns the key to pre-select for signing a withdrawal. /// /// Only keys whose private material is held locally are considered (via @@ -1345,6 +1363,36 @@ mod withdrawal_key_tests { assert_eq!(ids, vec![1]); } + /// An on-chain-only key (no local private material) can never be used to + /// sign a withdrawal (see `resolve_withdrawal_signing_key`), for any + /// role — including Developer. Enabling the gate for it previously led + /// `WithdrawalScreen` to a blank or failing screen. + #[test] + fn can_attempt_withdrawal_ignores_on_chain_only_keys_for_every_role() { + let without_keys = build_identity(IdentityType::User, vec![], vec![]); + let authentication = key(1, Purpose::AUTHENTICATION); + let on_chain_only = build_identity(IdentityType::User, vec![authentication], vec![]); + + for role in [UserRole::Everyday, UserRole::Power, UserRole::Developer] { + assert!(!without_keys.can_attempt_withdrawal(role)); + assert!( + !on_chain_only.can_attempt_withdrawal(role), + "on-chain-only AUTHENTICATION key must not enable withdrawal for {role:?}" + ); + } + } + + #[test] + fn can_attempt_withdrawal_uses_local_withdrawal_keys_for_every_role() { + let transfer = key(2, Purpose::TRANSFER); + let locally_signable = + build_identity(IdentityType::User, vec![transfer.clone()], vec![transfer]); + + for role in [UserRole::Everyday, UserRole::Power, UserRole::Developer] { + assert!(locally_signable.can_attempt_withdrawal(role)); + } + } + /// A wholly disabled key set leaves no signable withdrawal key. #[test] fn all_disabled_yields_no_withdrawal_key() { diff --git a/src/ui/identities/identities_screen.rs b/src/ui/identities/identities_screen.rs index be812f664..d4a17f94b 100644 --- a/src/ui/identities/identities_screen.rs +++ b/src/ui/identities/identities_screen.rs @@ -680,9 +680,19 @@ impl IdentitiesScreen { .app_context .fee_estimator() .estimate_credit_withdrawal(); - let can_withdraw = qualified_identity.identity.balance() > min_withdrawal_balance; + let has_withdrawal_balance = + qualified_identity.identity.balance() + > min_withdrawal_balance; + let can_attempt_withdrawal = + qualified_identity.can_attempt_withdrawal( + self.app_context.user_role(), + ); + let can_withdraw = has_withdrawal_balance + && can_attempt_withdrawal; - let withdraw_hover = if can_withdraw { + let withdraw_hover = if !can_attempt_withdrawal { + "No key is available for withdrawal in the current interface mode" + } else if has_withdrawal_balance { "Withdraw credits from this identity to a Dash Core address" } else { "Insufficient balance for withdrawal fees" diff --git a/src/ui/identities/top_up_identity_screen/by_platform_address.rs b/src/ui/identities/top_up_identity_screen/by_platform_address.rs index 1b8631c28..a79e2a435 100644 --- a/src/ui/identities/top_up_identity_screen/by_platform_address.rs +++ b/src/ui/identities/top_up_identity_screen/by_platform_address.rs @@ -169,7 +169,7 @@ impl TopUpIdentityScreen { ui.add_space(20.0); - // Top Up button + // Add funds button let has_valid_amount = self .platform_top_up_amount .as_ref() @@ -182,8 +182,8 @@ impl TopUpIdentityScreen { ui.horizontal(|ui| { let button_text = match step { - WalletFundedScreenStep::WaitingForPlatformAcceptance => "Topping Up...", - _ => "Top Up Identity", + WalletFundedScreenStep::WaitingForPlatformAcceptance => "Adding funds...", + _ => "Add funds", }; let button = egui::Button::new( diff --git a/src/ui/identities/top_up_identity_screen/by_receive_deposit.rs b/src/ui/identities/top_up_identity_screen/by_receive_deposit.rs index 5624baca4..8c49c00b1 100644 --- a/src/ui/identities/top_up_identity_screen/by_receive_deposit.rs +++ b/src/ui/identities/top_up_identity_screen/by_receive_deposit.rs @@ -128,7 +128,7 @@ impl TopUpIdentityScreen { } /// Render the "Receive a new deposit" funding method: a scannable deposit - /// address while waiting, then an editable amount and Top Up button once the + /// address while waiting, then an editable amount and Add funds button once the /// deposit arrives. A "Choose a different funding method" affordance is /// present throughout so the user is never trapped. pub fn render_ui_by_receive_deposit(&mut self, ui: &mut Ui, step_number: u32) -> AppAction { @@ -166,11 +166,10 @@ impl TopUpIdentityScreen { let has_valid_amount = self.funding_amount_exact.is_some_and(|d| d > 0); if has_valid_amount { - let button = - egui::Button::new(RichText::new("Top Up Identity").color(Color32::WHITE)) - .fill(DashColors::DASH_BLUE) - .frame(true) - .corner_radius(3.0); + let button = egui::Button::new(RichText::new("Add funds").color(Color32::WHITE)) + .fill(DashColors::DASH_BLUE) + .frame(true) + .corner_radius(3.0); if ui.add(button).clicked() { action = self.top_up_identity_clicked(FundingMethod::ReceiveDeposit); } @@ -184,10 +183,10 @@ impl TopUpIdentityScreen { ui.add_space(20.0); ui.vertical_centered(|ui| match step { WalletFundedScreenStep::WaitingForAssetLock => { - ui.heading("=> Waiting for Core Chain to produce proof of transfer of funds. <="); + ui.heading("Waiting for the Dash network to confirm the transfer."); } WalletFundedScreenStep::WaitingForPlatformAcceptance => { - ui.heading("=> Waiting for Platform acknowledgement <="); + ui.heading("Waiting for Platform to add the funds to the identity."); } _ => {} }); diff --git a/src/ui/identities/top_up_identity_screen/by_using_unused_asset_lock.rs b/src/ui/identities/top_up_identity_screen/by_using_unused_asset_lock.rs index 2a8400c4b..231468062 100644 --- a/src/ui/identities/top_up_identity_screen/by_using_unused_asset_lock.rs +++ b/src/ui/identities/top_up_identity_screen/by_using_unused_asset_lock.rs @@ -102,7 +102,7 @@ impl TopUpIdentityScreen { let mut new_style = (**ui.style()).clone(); new_style.spacing.button_padding = egui::vec2(10.0, 5.0); ui.set_style(new_style); - let button = egui::Button::new(RichText::new("Top Up Identity").color(Color32::WHITE)) + let button = egui::Button::new(RichText::new("Add funds").color(Color32::WHITE)) .fill(DashColors::DASH_BLUE) .frame(true) .corner_radius(3.0); @@ -114,7 +114,7 @@ impl TopUpIdentityScreen { ui.vertical_centered(|ui| match step { WalletFundedScreenStep::WaitingForPlatformAcceptance => { - ui.heading("=> Waiting for Platform acknowledgement <="); + ui.heading("Waiting for Platform to add the funds to the identity."); } WalletFundedScreenStep::Success => { ui.heading("...Success..."); diff --git a/src/ui/identities/top_up_identity_screen/by_using_unused_balance.rs b/src/ui/identities/top_up_identity_screen/by_using_unused_balance.rs index 8bb70208c..d3b8063b8 100644 --- a/src/ui/identities/top_up_identity_screen/by_using_unused_balance.rs +++ b/src/ui/identities/top_up_identity_screen/by_using_unused_balance.rs @@ -25,7 +25,7 @@ impl TopUpIdentityScreen { let dash_balance = spendable_balance as f64 * 1e-8; // Convert to DASH units ui.horizontal(|ui| { - ui.label(format!("Wallet Balance: {:.8} DASH", dash_balance)); + ui.label(format!("You can use {dash_balance:.8} DASH.")); }); } else { ui.label("No wallet selected"); @@ -102,8 +102,8 @@ impl TopUpIdentityScreen { // Extract the step from the RwLock to minimize borrow scope let step = self.current_step(); - // Only show the fee estimate and Top Up button once a positive amount - // is entered — otherwise clicking Top Up would silently no-op. + // Only show the fee estimate and Add funds button once a positive amount + // is entered — otherwise clicking Add funds would silently no-op. let has_valid_amount = self.funding_amount_exact.is_some_and(|d| d > 0); if !has_valid_amount { return action; @@ -135,11 +135,11 @@ impl TopUpIdentityScreen { ui.add_space(10.0); - // Top up button + // Add funds button let mut new_style = (**ui.style()).clone(); new_style.spacing.button_padding = egui::vec2(10.0, 5.0); ui.set_style(new_style); - let button = egui::Button::new(RichText::new("Top Up Identity").color(Color32::WHITE)) + let button = egui::Button::new(RichText::new("Add funds").color(Color32::WHITE)) .fill(DashColors::DASH_BLUE) .frame(true) .corner_radius(3.0); @@ -152,12 +152,10 @@ impl TopUpIdentityScreen { ui.vertical_centered(|ui| { match step { WalletFundedScreenStep::WaitingForAssetLock => { - ui.heading( - "=> Waiting for Core Chain to produce proof of transfer of funds. <=", - ); + ui.heading("Waiting for the Dash network to confirm the transfer."); } WalletFundedScreenStep::WaitingForPlatformAcceptance => { - ui.heading("=> Waiting for Platform acknowledgement <="); + ui.heading("Waiting for Platform to add the funds to the identity."); } WalletFundedScreenStep::Success => { ui.heading("...Success..."); diff --git a/src/ui/identities/top_up_identity_screen/mod.rs b/src/ui/identities/top_up_identity_screen/mod.rs index 8227cc52a..0a2711e8b 100644 --- a/src/ui/identities/top_up_identity_screen/mod.rs +++ b/src/ui/identities/top_up_identity_screen/mod.rs @@ -43,8 +43,8 @@ use egui::{ComboBox, ScrollArea, Ui}; use std::sync::atomic::Ordering; use std::sync::{Arc, RwLock}; -const WALLET_SELECTION_TOOLTIP: &str = "This wallet will provide the address for receiving funds \ -and create the asset lock transaction to top up your identity."; +const WALLET_SELECTION_TOOLTIP: &str = + "Choose the wallet that will supply or receive the Dash used to add funds to this identity."; fn pending_backend_tasks_action( mut lock_fetches: Vec, @@ -569,7 +569,7 @@ impl TopUpIdentityScreen { amount_input.set_max_exceeded_hint(fee_hint); // Pre-fill (once) with the fee-reserve-capped maximum when a deposit just - // arrived, so the amount and Top Up button are populated but still editable. + // arrived, so the amount and Add funds button are populated but still editable. if should_prefill && let Some(max) = max_amount { amount_input.set_value(Amount::dash_from_credits(max)); } @@ -668,7 +668,7 @@ impl ScreenLike for TopUpIdentityScreen { minimum_credits, ); // Pre-fill the amount with the fee-reserve-capped balance when the - // deposit lands, so the field and Top Up button populate. + // deposit lands, so the field and Add funds button populate. if prefill.is_some() { self.prefill_funding_amount = true; } @@ -731,7 +731,7 @@ impl ScreenLike for TopUpIdentityScreen { &self.app_context, vec![ ("Identities", AppAction::GoToMainScreen), - ("Top Up Identity", AppAction::None), + ("Add Funds", AppAction::None), ], vec![], ); @@ -777,7 +777,7 @@ impl ScreenLike for TopUpIdentityScreen { ui.separator(); ui.add_space(10.0); - ui.heading("Follow these steps to top up your identity:"); + ui.heading("Follow these steps to add funds to your identity:"); ui.add_space(15.0); let mut step_number = 1; @@ -813,8 +813,8 @@ impl ScreenLike for TopUpIdentityScreen { if wallet_count > 1 { ui.horizontal(|ui| { ui.heading(format!( - "{}. Choose the wallet to use to top up this identity.", - step_number + "{step_number}. Choose the wallet to use to add funds to this \ + identity." )); ui.add_space(10.0); diff --git a/src/ui/identities/withdraw_screen.rs b/src/ui/identities/withdraw_screen.rs index b1ddb6889..f4fdcb8fe 100644 --- a/src/ui/identities/withdraw_screen.rs +++ b/src/ui/identities/withdraw_screen.rs @@ -70,30 +70,13 @@ impl WithdrawalScreen { let max_amount = identity.identity.balance(); // Only pre-select a withdrawal key whose private material is held locally // (TRANSFER preferred, OWNER fallback). Pre-selecting an on-chain-only key - // the signer cannot use is what surfaced the raw signing error. + // the signer cannot use is what surfaced the raw signing error. This is the + // same invariant `can_attempt_withdrawal` gates on for every role, so a + // `None` here means the gate itself should have kept this screen from + // being reachable in the first place. let selected_key: Option = identity .default_withdrawal_key() - .map(|qk| qk.identity_public_key.clone()) - .or_else(|| { - // Only the Developer role can actually sign with an on-chain-only - // key (the signing override in `state_transition_options` plus the - // Developer branch of the `has_keys` gate below). Pre-selecting one - // for any lower role gives a key the signer cannot use, so this - // fallback matches that Developer gate. - app_context - .user_role() - .at_least(UserRole::Developer) - .then(|| { - identity.identity.get_first_public_key_matching( - Purpose::TRANSFER, - SecurityLevel::full_range().into(), - KeyType::all_key_types().into(), - false, - ) - }) - .flatten() - .cloned() - }); + .map(|qk| qk.identity_public_key.clone()); // With no key there is nothing to resolve a wallet from; skip the call so // get_selected_wallet's "no key provided" Err path stays unreachable here. let selected_wallet = match selected_key.as_ref() { @@ -435,19 +418,16 @@ impl ScreenLike for WithdrawalScreen { }); ui.add_space(10.0); - let has_keys = if self.app_context.user_role().at_least(UserRole::Developer) { - !self.identity.identity.public_keys().is_empty() - } else { - !self.identity.available_withdrawal_keys().is_empty() - }; - - if !has_keys { + if !self + .identity + .can_attempt_withdrawal(self.app_context.user_role()) + { ui.colored_label( egui::Color32::DARK_RED, - format!( - "You do not have any withdrawal keys loaded for this {identity_type} identity. Note that TRANSFER or OWNER keys are used for withdrawals.", - identity_type = self.identity.identity_type - )); + "This identity has no loaded key that can approve a withdrawal. Load or \ + import a key that can send funds from this identity, or use a different \ + identity.", + ); ui.add_space(10.0); if self.identity.identity_type != IdentityType::User { diff --git a/src/ui/identity/home.rs b/src/ui/identity/home.rs index 1c9468489..9910bcbf2 100644 --- a/src/ui/identity/home.rs +++ b/src/ui/identity/home.rs @@ -7,18 +7,18 @@ //! identity-type badge, network pill. Two variants: social profile set //! (`IdentityHeroCard` with display name) and no social profile (type-glyph //! monogram + inline `Set up your social profile` card below the hero). -//! 2. Quick-actions row: **Send**, **Receive**, **Add contact**. `Add contact` -//! is gated behind a social profile (see §B.3). -//! 3. Secondary actions row (ghost buttons): `Add funds`, `Send to wallet`, -//! `Send to another identity`. All three visible for all personas (§B.2). -//! 4. Onboarding checklist strip (until all three steps are complete or the +//! 2. Actions row: **Add funds**, **Send to wallet**, **Send to another +//! identity**, **Add contact**. `Add contact` is gated behind a social +//! profile (see §B.3). +//! 3. Onboarding checklist strip (until all three steps are complete or the //! user dismisses it). -//! 5. Recent activity preview (up to 5 rows), currently an empty-state +//! 4. Recent activity preview (up to 5 rows), currently an empty-state //! preview; the `See all activity` link routes to the Activity tab. //! Richer content is parked until the activity aggregator lands. -//! 6. Advanced details expander (raw Identity ID, revision, last updated). +//! 5. Advanced details expander (raw Identity ID, revision, last updated). //! -//! Strings are taken verbatim from §B.2 / §B.3 and the wording audit in §C. +//! Strings follow the Identity Home action-set redesign and the wording audit +//! in §C. //! //! This module is state-less per-frame: the only persisted state is the //! `dismissed_checklist` flag owned by the calling hub screen and passed in @@ -33,7 +33,7 @@ use crate::model::qualified_identity::QualifiedIdentity; use crate::ui::ScreenType; use crate::ui::identities::register_dpns_name_screen::RegisterDpnsNameSource; use crate::ui::identity::tabs::IdentityHubTab; -use crate::ui::theme::{DashColors, ResponseExt, Shape, Spacing, network_label}; +use crate::ui::theme::{ComponentStyles, DashColors, ResponseExt, Shape, Spacing, network_label}; #[cfg(test)] use dash_sdk::dashcore_rpc::dashcore::Network; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; @@ -91,18 +91,14 @@ pub enum HomeOutcome { /// every button produces a real side effect. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum HomeButton { - /// Quick-action `Send`. - Send, - /// Quick-action `Receive`. - Receive, - /// Quick-action `Add contact` (enabled path — gated callers do not reach + /// Action-row `Add contact` (enabled path — gated callers do not reach /// the dispatcher). AddContact, - /// Secondary action `Add funds`. + /// Action-row `Add funds`. AddFunds, - /// Secondary action `Send to wallet`. + /// Action-row `Send to wallet`. SendToWallet, - /// Secondary action `Send to another identity`. + /// Action-row `Send to another identity`. SendToAnotherIdentity, /// Inline social profile card `Add a display name` CTA. The hero card's /// separate `Pick a username` CTA (when no DPNS handle exists) maps to @@ -154,7 +150,7 @@ pub enum HomeScreenKind { Transfer, /// `Screen::TopUpIdentityScreen` — move wallet Dash into the identity. TopUp, - /// `Screen::WithdrawalScreen` — move identity credits back to the wallet. + /// `Screen::WithdrawalScreen` — move identity credits to a Core Dash address. Withdrawal, /// `Screen::RegisterDpnsNameScreen` — register a DPNS name for the identity. RegisterDpnsName, @@ -175,8 +171,8 @@ pub fn home_button_kind(button: HomeButton) -> HomeButtonKind { use HomeButtonKind::{OpenScreen, Outcome}; use HomeScreenKind::*; match button { - HomeButton::Send | HomeButton::SendToAnotherIdentity => OpenScreen(Transfer), - HomeButton::Receive | HomeButton::AddFunds => OpenScreen(TopUp), + HomeButton::SendToAnotherIdentity => OpenScreen(Transfer), + HomeButton::AddFunds => OpenScreen(TopUp), HomeButton::SendToWallet => OpenScreen(Withdrawal), HomeButton::PickUsernameHero | HomeButton::ChecklistPickUsername => { OpenScreen(RegisterDpnsName) @@ -308,18 +304,11 @@ pub fn render( // --- Inline "Set up your social profile" card (no-profile variant) - // - // Per wireframe §B.3 this prompt belongs immediately below the hero for - // the no-profile variant (V2 visual fix). It was previously rendered after - // the secondary-actions row, leaving the hero visually empty and the card - // buried. Moved here so the two form a single compact visual group with no - // gap between them. With V1 applied (hero sized to content, no min-height - // floor) the hero is already compact — no extra space is added before this - // card, and the standard Spacing::MD below separates both from the actions. + // Per wireframe §B.3 this prompt belongs immediately below the compact hero, + // with standard spacing separating the group from the actions. // - // V2/V3 conflict: the onboarding checklist already contains a - // "Set a display name" step that routes to Settings — the same action as - // this card. When the checklist is visible (not dismissed), suppress this - // card so the user sees exactly one prompt for the action. + // The onboarding checklist contains the same "Set a display name" action, + // so suppress this card while that checklist is visible. let checklist_covers_profile = !hero_has_social_profile && !state.dismissed_checklist; if !hero_has_social_profile && !state.skipped_social_profile @@ -330,52 +319,50 @@ pub fn render( } ui.add_space(Spacing::MD); - // --- Quick actions row -------------------------------------------- - ui.horizontal(|ui| { - // "Send" routes to the identity Transfer screen (identity→identity - // credit transfer). The tooltip therefore describes that action, not - // a wallet-Dash send (T30). - if primary_quick_action( - ui, - "Send", - "Transfer credits from this identity to another identity.", - ) - .clicked() + // --- Actions row -------------------------------------------------- + ui.horizontal_wrapped(|ui| { + if ComponentStyles::add_primary_button(ui, "Add funds") + .clickable_tooltip("Move Dash from your wallet into this identity.") + .clicked() { - apply(HomeButton::Send); + apply(HomeButton::AddFunds); } - ui.add_space(Spacing::SM); - // "Receive" routes to TopUpIdentity (wallet→identity credits). The - // tooltip therefore describes adding funds from the wallet, not showing - // a QR code for inbound Dash (T30). - if primary_quick_action( - ui, - "Receive", - "Move Dash from your wallet into this identity.", - ) - .clicked() + + let send_to_wallet = ComponentStyles::secondary_button("Send to wallet", dark_mode); + if !identity.can_attempt_withdrawal(app_context.user_role()) { + ui.add_enabled(false, send_to_wallet).disabled_tooltip( + "Sending to a wallet is unavailable because this identity has no key available \ + for withdrawal in the current interface mode. Load or import an eligible key, \ + change the interface mode if another on-chain key is available, or use a \ + different identity.", + ); + } else if ui + .add(send_to_wallet) + .clickable_tooltip( + "Move Dash out of this identity to a Dash address, such as one from your wallet.", + ) + .clicked() { - apply(HomeButton::Receive); + apply(HomeButton::SendToWallet); + } + + if ComponentStyles::add_secondary_button(ui, "Send to another identity", dark_mode) + .clickable_tooltip( + "Send Dash from this identity to another identity. You can also send to a \ + Platform address.", + ) + .clicked() + { + apply(HomeButton::SendToAnotherIdentity); } - ui.add_space(Spacing::SM); // Add contact is gated behind a social profile per §B.3. - let add_contact = egui::Button::new( - RichText::new("Add contact") - .strong() - .color(DashColors::text_primary(dark_mode)), - ) - .fill(DashColors::surface(dark_mode)) - .stroke(Stroke::new( - Shape::BORDER_WIDTH, - DashColors::border(dark_mode), - )) - .min_size(egui::vec2(160.0, 40.0)); + let add_contact = ComponentStyles::secondary_button("Add contact", dark_mode); if hero_has_social_profile { - let resp = ui + let response = ui .add(add_contact) .clickable_tooltip("Find someone by username and add them to your contacts."); - if resp.clicked() { + if response.clicked() { apply(HomeButton::AddContact); } } else { @@ -387,43 +374,6 @@ pub fn render( }); ui.add_space(Spacing::MD); - // --- Secondary actions row ---------------------------------------- - ui.horizontal(|ui| { - if ghost_action( - ui, - "Add funds", - "Move Dash from your wallet into this identity.", - dark_mode, - ) - .clicked() - { - apply(HomeButton::AddFunds); - } - ui.add_space(Spacing::SM); - if ghost_action( - ui, - "Send to wallet", - "Convert your identity balance back to spendable Dash in your wallet.", - dark_mode, - ) - .clicked() - { - apply(HomeButton::SendToWallet); - } - ui.add_space(Spacing::SM); - if ghost_action( - ui, - "Send to another identity", - "Transfer Dash directly from this identity to another identity.", - dark_mode, - ) - .clicked() - { - apply(HomeButton::SendToAnotherIdentity); - } - }); - ui.add_space(Spacing::MD); - // --- Onboarding checklist ----------------------------------------- if !state.dismissed_checklist { // Extract the primary DPNS handle for the done-subtext ("You are @@ -692,27 +642,6 @@ fn render_empty(ui: &mut Ui, dark_mode: bool) { }); } -/// Build a primary (filled, Dash-blue) button used in the quick-actions row. -/// Returns the `Response` so the caller can attach click handling inline. -fn primary_quick_action(ui: &mut Ui, label: &str, tooltip: &str) -> egui::Response { - let btn = egui::Button::new(RichText::new(label).strong().color(Color32::WHITE)) - .fill(DashColors::DASH_BLUE) - .min_size(egui::vec2(140.0, 40.0)); - ui.add(btn).clickable_tooltip(tooltip) -} - -/// Build a ghost (outlined) button used in the secondary-actions row. -fn ghost_action(ui: &mut Ui, label: &str, tooltip: &str, dark_mode: bool) -> egui::Response { - let btn = egui::Button::new(RichText::new(label).color(DashColors::text_primary(dark_mode))) - .fill(Color32::TRANSPARENT) - .stroke(Stroke::new( - Shape::BORDER_WIDTH, - DashColors::border(dark_mode), - )) - .min_size(egui::vec2(160.0, 36.0)); - ui.add(btn).clickable_tooltip(tooltip) -} - /// Render the inline social profile card shown below the hero when the user /// has no display name. Returns `true` when the primary `Add a display name` /// button is clicked. @@ -838,9 +767,7 @@ mod tests { // future catch-all). The enum is non-`Default`-constructible and // every variant is enumerated in `ALL_HOME_BUTTONS` — if you add a // new button, you MUST extend this list or the "coverage" test - // fails. Guards against #842, where the hub screen discarded the - // tab's action value and every quick/secondary action silently - // returned `AppAction::None`. + // fails. // ----------------------------------------------------------------- /// Every `HomeButton` variant. The list is hand-maintained so that @@ -848,8 +775,6 @@ mod tests { /// below — adding a variant without updating this list is a compile /// error. const ALL_HOME_BUTTONS: &[HomeButton] = &[ - HomeButton::Send, - HomeButton::Receive, HomeButton::AddContact, HomeButton::AddFunds, HomeButton::SendToWallet, @@ -872,8 +797,6 @@ mod tests { for button in ALL_HOME_BUTTONS { #[allow(unreachable_patterns)] let _: () = match *button { - HomeButton::Send => (), - HomeButton::Receive => (), HomeButton::AddContact => (), HomeButton::AddFunds => (), HomeButton::SendToWallet => (), @@ -903,30 +826,25 @@ mod tests { } } - /// Pin the specific mapping for the most-clicked surfaces so a future - /// rename or swap (e.g. routing `Send` to `TopUp`) is caught. #[test] - fn primary_send_receive_mappings_are_stable() { - assert_eq!( - home_button_kind(HomeButton::Send), - HomeButtonKind::OpenScreen(HomeScreenKind::Transfer), - ); - assert_eq!( - home_button_kind(HomeButton::Receive), - HomeButtonKind::OpenScreen(HomeScreenKind::TopUp), - ); - assert_eq!( - home_button_kind(HomeButton::AddFunds), - HomeButtonKind::OpenScreen(HomeScreenKind::TopUp), - ); - assert_eq!( - home_button_kind(HomeButton::SendToWallet), - HomeButtonKind::OpenScreen(HomeScreenKind::Withdrawal), - ); - assert_eq!( - home_button_kind(HomeButton::SendToAnotherIdentity), - HomeButtonKind::OpenScreen(HomeScreenKind::Transfer), - ); + fn action_row_buttons_have_distinct_destinations() { + let destinations = [ + HomeButton::AddFunds, + HomeButton::SendToWallet, + HomeButton::SendToAnotherIdentity, + ] + .map(|button| match home_button_kind(button) { + HomeButtonKind::OpenScreen(screen) => screen, + HomeButtonKind::Outcome(outcome) => { + panic!("action-row button {button:?} produced hub outcome {outcome:?}") + } + }); + + for (index, destination) in destinations.iter().enumerate() { + for other in &destinations[index + 1..] { + assert_ne!(destination, other); + } + } } #[test] diff --git a/src/wallet_backend/mod.rs b/src/wallet_backend/mod.rs index 66efe0213..52344bf45 100644 --- a/src/wallet_backend/mod.rs +++ b/src/wallet_backend/mod.rs @@ -2892,13 +2892,46 @@ fn map_shielded_op_error(e: platform_wallet::error::PlatformWalletError) -> Task } } +/// Classify an SDK error via [`TaskError::from`], preferring its richer +/// classification (e.g. `AssetLockOutPointAlreadyConsumed`, +/// `IdentityInsufficientBalance`) when one applies. When the SDK error is an +/// unclassified `StateTransitionBroadcastError` — [`TaskError::from`]'s +/// generic `PlatformRejected` bucket — that carries no funding-operation +/// context, so this unwraps it and hands the underlying error to `on_generic` +/// to build the caller's operation-specific rejection instead (e.g. +/// `IdentityCreateRejected`), preserving the recovery guidance that variant +/// carries (retained funding lock, affected identity, etc.). +fn classify_sdk_error_or( + sdk_error: dash_sdk::Error, + on_generic: impl FnOnce(platform_wallet::error::PlatformWalletError) -> TaskError, +) -> TaskError { + match TaskError::from(sdk_error) { + TaskError::PlatformRejected { source_error } => on_generic( + platform_wallet::error::PlatformWalletError::Sdk(*source_error), + ), + classified => classified, + } +} + /// Classify a `PlatformWalletError` returned from /// `register_identity_with_funding` into a typed `TaskError`. Network / /// broadcast rejections become `IdentityCreateRejected`; asset-lock /// finality failures become `AssetLockFinalityTimeout`; everything else -/// falls through to the generic `WalletBackend` wrapper. Structural match -/// — never parses error strings. +/// falls through to the generic `WalletBackend` wrapper. SDK errors use the +/// richer `TaskError` classifier before this coarse mapping, falling back to +/// `IdentityCreateRejected` for an unclassified rejection (see +/// [`classify_sdk_error_or`]) so generic broadcast failures still carry +/// registration's funding-lock recovery guidance. Structural match — never +/// parses error strings. fn map_identity_register_error(e: platform_wallet::error::PlatformWalletError) -> TaskError { + let e = match e { + platform_wallet::error::PlatformWalletError::Sdk(sdk_error) => { + return classify_sdk_error_or(sdk_error, |e| TaskError::IdentityCreateRejected { + source: Box::new(e), + }); + } + other => other, + }; match identity_op_error_kind(&e) { IdentityOpErrorKind::Rejected => TaskError::IdentityCreateRejected { source: Box::new(e), @@ -2916,11 +2949,24 @@ fn map_identity_register_error(e: platform_wallet::error::PlatformWalletError) - /// Same as [`map_identity_register_error`] but for the top-up façade — /// the `identity_id` is carried into the rejection variant so the user- -/// facing message can reference the affected identity. +/// facing message can reference the affected identity. SDK errors use the +/// richer `TaskError` classifier before the coarse identity-operation +/// mapping, falling back to `IdentityTopUpRejected` (see +/// [`classify_sdk_error_or`]) for an unclassified rejection so the affected +/// identity is not lost. fn map_identity_top_up_error( identity_id: dash_sdk::platform::Identifier, e: platform_wallet::error::PlatformWalletError, ) -> TaskError { + let e = match e { + platform_wallet::error::PlatformWalletError::Sdk(sdk_error) => { + return classify_sdk_error_or(sdk_error, |e| TaskError::IdentityTopUpRejected { + identity_id, + source: Box::new(e), + }); + } + other => other, + }; match identity_op_error_kind(&e) { IdentityOpErrorKind::Rejected => TaskError::IdentityTopUpRejected { identity_id, @@ -2965,8 +3011,19 @@ fn platform_warm_start_seed( /// Shares the identity-flow bucketing: an asset-lock finality timeout reuses /// [`TaskError::AssetLockFinalityTimeout`], a network/broadcast rejection lands /// in [`TaskError::PlatformAddressFundRejected`], and everything else falls -/// through to the generic [`TaskError::WalletBackend`] envelope. +/// through to the generic [`TaskError::WalletBackend`] envelope. SDK errors use +/// the richer `TaskError` classifier before this coarse mapping, falling back +/// to `PlatformAddressFundRejected` (see [`classify_sdk_error_or`]) for an +/// unclassified rejection so the existing-lock recovery instructions survive. fn map_platform_address_fund_error(e: platform_wallet::error::PlatformWalletError) -> TaskError { + let e = match e { + platform_wallet::error::PlatformWalletError::Sdk(sdk_error) => { + return classify_sdk_error_or(sdk_error, |e| TaskError::PlatformAddressFundRejected { + source: Box::new(e), + }); + } + other => other, + }; match identity_op_error_kind(&e) { IdentityOpErrorKind::Rejected => TaskError::PlatformAddressFundRejected { source: Box::new(e), @@ -3086,6 +3143,44 @@ fn identity_op_error_kind(e: &platform_wallet::error::PlatformWalletError) -> Id mod tests { use super::*; + fn already_consumed_wallet_error() -> platform_wallet::error::PlatformWalletError { + use dash_sdk::dpp::consensus::basic::identity::IdentityAssetLockTransactionOutPointAlreadyConsumedError; + use dash_sdk::dpp::dashcore::hashes::Hash; + let consensus = dash_sdk::dpp::consensus::ConsensusError::from( + IdentityAssetLockTransactionOutPointAlreadyConsumedError::new( + dash_sdk::dpp::dashcore::Txid::from_byte_array([0u8; 32]), + 0, + ), + ); + let broadcast_error = dash_sdk::error::StateTransitionBroadcastError { + code: 40000, + message: "already consumed".to_string(), + cause: Some(consensus), + }; + platform_wallet::error::PlatformWalletError::Sdk( + dash_sdk::Error::StateTransitionBroadcastError(broadcast_error), + ) + } + + /// A broadcast rejection whose consensus cause has no dedicated + /// `TaskError` classification — [`TaskError::from`] buckets this as the + /// generic `PlatformRejected`, which is exactly the case + /// [`classify_sdk_error_or`] must unwrap and hand back to the caller's + /// funding-operation-specific envelope. + fn unmapped_broadcast_rejection_wallet_error() -> platform_wallet::error::PlatformWalletError { + use dash_sdk::dpp::consensus::basic::UnsupportedVersionError; + let consensus = + dash_sdk::dpp::consensus::ConsensusError::from(UnsupportedVersionError::new(1, 2, 3)); + let broadcast_error = dash_sdk::error::StateTransitionBroadcastError { + code: 40001, + message: "unsupported version".to_string(), + cause: Some(consensus), + }; + platform_wallet::error::PlatformWalletError::Sdk( + dash_sdk::Error::StateTransitionBroadcastError(broadcast_error), + ) + } + /// Covers the managed-state seam after contact xpub derivation, including revision gating. /// Wallet resolution and manager-lock wiring still require backend-e2e infrastructure. #[test] @@ -3335,6 +3430,27 @@ mod tests { ); } + #[test] + fn map_identity_register_error_classifies_already_consumed_asset_lock() { + let mapped = map_identity_register_error(already_consumed_wallet_error()); + assert!( + matches!(mapped, TaskError::AssetLockOutPointAlreadyConsumed { .. }), + "Expected AssetLockOutPointAlreadyConsumed, got: {mapped:?}" + ); + } + + /// An SDK broadcast rejection with no dedicated classification must still + /// land in `IdentityCreateRejected` — not the generic `PlatformRejected` + /// — so registration keeps its retained-funding-lock recovery guidance. + #[test] + fn map_identity_register_error_falls_back_for_unmapped_broadcast_rejection() { + let mapped = map_identity_register_error(unmapped_broadcast_rejection_wallet_error()); + assert!( + matches!(mapped, TaskError::IdentityCreateRejected { .. }), + "Expected IdentityCreateRejected, got: {mapped:?}" + ); + } + /// I3: an asset-lock finality failure surfaced during identity register /// maps to `AssetLockFinalityTimeout`, regardless of which finality /// sub-variant fired upstream. @@ -3383,6 +3499,34 @@ mod tests { } } + #[test] + fn map_identity_top_up_error_classifies_already_consumed_asset_lock() { + let mapped = map_identity_top_up_error( + dash_sdk::platform::Identifier::random(), + already_consumed_wallet_error(), + ); + assert!( + matches!(mapped, TaskError::AssetLockOutPointAlreadyConsumed { .. }), + "Expected AssetLockOutPointAlreadyConsumed, got: {mapped:?}" + ); + } + + /// An unclassified SDK broadcast rejection during top-up must still land + /// in `IdentityTopUpRejected`, carrying the affected identity, rather + /// than the generic `PlatformRejected`. + #[test] + fn map_identity_top_up_error_falls_back_for_unmapped_broadcast_rejection() { + let identity_id = dash_sdk::platform::Identifier::random(); + let mapped = + map_identity_top_up_error(identity_id, unmapped_broadcast_rejection_wallet_error()); + match mapped { + TaskError::IdentityTopUpRejected { + identity_id: got, .. + } => assert_eq!(got, identity_id, "identity_id must be preserved"), + other => panic!("Expected IdentityTopUpRejected, got: {other:?}"), + } + } + /// A top-up against an identity the wallet has not registered /// (`IdentityNotFound` / `IdentityIndexNotSet`) maps to the dedicated /// `IdentityNotManaged` envelope — not the "retry in a moment" fallback — @@ -3517,6 +3661,28 @@ mod tests { ); } + #[test] + fn map_platform_address_fund_error_classifies_already_consumed_asset_lock() { + let mapped = map_platform_address_fund_error(already_consumed_wallet_error()); + assert!( + matches!(mapped, TaskError::AssetLockOutPointAlreadyConsumed { .. }), + "Expected AssetLockOutPointAlreadyConsumed, got: {mapped:?}" + ); + } + + /// An unclassified SDK broadcast rejection during platform-address + /// funding must still land in `PlatformAddressFundRejected`, preserving + /// the existing-lock recovery instructions, rather than the generic + /// `PlatformRejected`. + #[test] + fn map_platform_address_fund_error_falls_back_for_unmapped_broadcast_rejection() { + let mapped = map_platform_address_fund_error(unmapped_broadcast_rejection_wallet_error()); + assert!( + matches!(mapped, TaskError::PlatformAddressFundRejected { .. }), + "Expected PlatformAddressFundRejected, got: {mapped:?}" + ); + } + /// An asset-lock finality failure surfaced during orchestrated platform /// funding reuses the shared `AssetLockFinalityTimeout` envelope. #[test] diff --git a/tests/kittest/identity_home.rs b/tests/kittest/identity_home.rs new file mode 100644 index 000000000..6c83124bd --- /dev/null +++ b/tests/kittest/identity_home.rs @@ -0,0 +1,315 @@ +use crate::support::{fresh_app_context, with_isolated_data_dir}; +use dash_evo_tool::app::AppAction; +use dash_evo_tool::context::AppContext; +use dash_evo_tool::model::qualified_identity::encrypted_key_storage::{KeyStorage, PrivateKeyData}; +use dash_evo_tool::model::qualified_identity::qualified_identity_public_key::QualifiedIdentityPublicKey; +use dash_evo_tool::model::qualified_identity::{ + IdentityStatus, IdentityType, PrivateKeyTarget, QualifiedIdentity, +}; +use dash_evo_tool::model::user_role::UserRole; +use dash_evo_tool::ui::Screen; +use dash_evo_tool::ui::identity::home::{self, HomeOutcome, HomeState}; +use dash_evo_tool::ui::identity::profile_cache::{ProfileCache, ProfileFields}; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::identity::identity_public_key::accessors::v0::{ + IdentityPublicKeyGettersV0, IdentityPublicKeySettersV0, +}; +use dash_sdk::dpp::identity::{Identity, KeyID, Purpose, SecurityLevel}; +use dash_sdk::dpp::version::PlatformVersion; +use dash_sdk::platform::{Identifier, IdentityPublicKey}; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use std::collections::BTreeMap; +use std::sync::Arc; + +fn key(id: KeyID, purpose: Purpose) -> IdentityPublicKey { + let mut key = IdentityPublicKey::random_key(id, Some(id as u64), PlatformVersion::latest()); + key.set_id(id); + key.set_purpose(purpose); + key.set_security_level(SecurityLevel::CRITICAL); + key +} + +fn seed_identity( + app_context: &Arc, + on_chain: Vec, + with_private: Vec, +) -> Identifier { + let public_keys = on_chain.into_iter().map(|key| (key.id(), key)).collect(); + let identity = Identity::new_with_id_and_keys( + Identifier::from([0xA5; 32]), + public_keys, + PlatformVersion::latest(), + ) + .expect("identity"); + let identity_id = identity.id(); + + let mut private_keys = BTreeMap::new(); + for key in with_private { + private_keys.insert( + (PrivateKeyTarget::PrivateKeyOnMainIdentity, key.id()), + ( + QualifiedIdentityPublicKey::from(key), + PrivateKeyData::InVault, + ), + ); + } + + let qualified_identity = QualifiedIdentity { + identity, + associated_voter_identity: None, + associated_operator_identity: None, + associated_owner_key_id: None, + identity_type: IdentityType::User, + alias: Some("Home test identity".to_string()), + private_keys: KeyStorage { private_keys }, + dpns_names: vec![], + associated_wallets: BTreeMap::new(), + secret_access: None, + wallet_index: None, + top_ups: BTreeMap::new(), + status: IdentityStatus::Active, + network: app_context.network(), + }; + + app_context + .insert_local_qualified_identity(&qualified_identity, &None) + .expect("seed identity"); + app_context.set_selected_identity(Some(identity_id)); + identity_id +} + +/// Harness state for `home::render`: its own mutable inputs (`HomeState`, +/// `ProfileCache`) plus the `(AppAction, HomeOutcome)` pair the most recent +/// render produced. Capturing the result (rather than discarding it, as a +/// prior version of this harness did) is what lets tests assert a clicked +/// button actually routes to its intended destination, not merely that the +/// click was accepted. +type HomeHarnessState = (HomeState, ProfileCache, Option<(AppAction, HomeOutcome)>); + +fn mount_home(app_context: Arc) -> Harness<'static, HomeHarnessState> { + let mut harness = Harness::builder() + .with_size(egui::vec2(1280.0, 900.0)) + .build_ui_state( + move |ui, state: &mut HomeHarnessState| { + let result = home::render(ui, &app_context, &state.0, &mut state.1); + // `Harness::run`/`step` internally renders extra idle frames + // (it loops until no immediate repaint is pending), and + // `home::render` resets its `(AppAction, HomeOutcome)` pair + // to the no-op value on every call. Only overwrite the + // captured result on a real (non-no-op) frame, so a click's + // outcome survives whatever idle re-renders follow it in the + // same `run()`/`step()` call. + if !matches!(result.0, AppAction::None) || result.1 != HomeOutcome::None { + state.2 = Some(result); + } + }, + (HomeState::default(), ProfileCache::default(), None), + ); + harness.run(); + harness +} + +/// The `(AppAction, HomeOutcome)` produced by the most recent render — the +/// frame's result of processing whichever button was clicked before +/// `harness.run()`. +fn last_result<'a>( + harness: &'a Harness<'static, HomeHarnessState>, +) -> &'a (AppAction, HomeOutcome) { + harness + .state() + .2 + .as_ref() + .expect("home::render must run at least once before a result is available") +} + +#[test] +fn home_send_to_wallet_disabled_for_everyday_without_local_withdrawal_key() { + with_isolated_data_dir(|| { + let (_runtime, app_context) = fresh_app_context(); + app_context.set_user_role(UserRole::Everyday); + seed_identity(&app_context, vec![key(1, Purpose::AUTHENTICATION)], vec![]); + + let mut harness = mount_home(app_context); + let button = harness.get_by_label("Send to wallet"); + assert!(button.accesskit_node().is_disabled()); + + button.hover(); + harness.run(); + assert!( + harness + .query_by_label_contains( + "no key available for withdrawal in the current interface mode" + ) + .is_some(), + "the disabled tooltip must explain the role-aware capability requirement" + ); + }); +} + +/// An on-chain-only key (no locally held private material) can never be +/// used to sign a withdrawal — `resolve_withdrawal_signing_key` enforces +/// this for every role, Developer included, since no signing override is +/// wired into the withdrawal backend task. Enabling the button here used to +/// route Developer-role users into a `WithdrawalScreen` that rendered no +/// form at all (`selected_key` stayed `None`); it must stay disabled instead. +#[test] +fn home_send_to_wallet_disabled_for_developer_with_on_chain_key_only() { + with_isolated_data_dir(|| { + let (_runtime, app_context) = fresh_app_context(); + app_context.set_user_role(UserRole::Developer); + seed_identity(&app_context, vec![key(1, Purpose::AUTHENTICATION)], vec![]); + + let harness = mount_home(app_context); + let button = harness.get_by_label("Send to wallet"); + assert!(button.accesskit_node().is_disabled()); + }); +} + +#[test] +fn home_send_to_wallet_enabled_for_everyday_with_local_transfer_key() { + with_isolated_data_dir(|| { + let (_runtime, app_context) = fresh_app_context(); + app_context.set_user_role(UserRole::Everyday); + let transfer_key = key(1, Purpose::TRANSFER); + seed_identity(&app_context, vec![transfer_key.clone()], vec![transfer_key]); + + let harness = mount_home(app_context); + let button = harness.get_by_label("Send to wallet"); + assert!(!button.accesskit_node().is_disabled()); + }); +} + +/// The prior version of this suite queried only `Send to wallet`, so `render` +/// could omit `Add funds` / `Send to another identity` / `Add contact`, or +/// render one of them twice, without failing anything here. Assert the full +/// four-button row is present, each exactly once. +#[test] +fn home_action_row_renders_all_four_buttons_exactly_once() { + with_isolated_data_dir(|| { + let (_runtime, app_context) = fresh_app_context(); + app_context.set_user_role(UserRole::Everyday); + let transfer_key = key(1, Purpose::TRANSFER); + seed_identity(&app_context, vec![transfer_key.clone()], vec![transfer_key]); + + let harness = mount_home(app_context); + for label in [ + "Add funds", + "Send to wallet", + "Send to another identity", + "Add contact", + ] { + assert_eq!( + harness.query_all_by_label(label).count(), + 1, + "expected exactly one {label:?} button in the action row" + ); + } + }); +} + +/// Clicking `Add funds` must open the top-up screen — not merely be +/// clickable. `mount_home` used to discard `home::render`'s returned +/// `(AppAction, HomeOutcome)`, so a button wired to the wrong destination +/// (or to none at all) would have passed every test in this file. +#[test] +fn home_add_funds_click_opens_top_up_screen() { + with_isolated_data_dir(|| { + let (_runtime, app_context) = fresh_app_context(); + app_context.set_user_role(UserRole::Everyday); + seed_identity(&app_context, vec![key(1, Purpose::AUTHENTICATION)], vec![]); + + let mut harness = mount_home(app_context); + harness.get_by_label("Add funds").click(); + // A single settle pass: `home::render` resets `action` to `None` at + // the top of every call, so an extra idle frame here (no queued + // input) would overwrite this frame's real result with a no-op one. + harness.run(); + + let (action, _outcome) = last_result(&harness); + assert!( + matches!(action, AppAction::AddScreen(Screen::TopUpIdentityScreen(_))), + "expected Add funds to open TopUpIdentityScreen, got {action:?}" + ); + }); +} + +/// Clicking an enabled `Send to wallet` must open the withdrawal screen. +#[test] +fn home_send_to_wallet_click_opens_withdrawal_screen() { + with_isolated_data_dir(|| { + let (_runtime, app_context) = fresh_app_context(); + app_context.set_user_role(UserRole::Everyday); + let transfer_key = key(1, Purpose::TRANSFER); + seed_identity(&app_context, vec![transfer_key.clone()], vec![transfer_key]); + + let mut harness = mount_home(app_context); + harness.get_by_label("Send to wallet").click(); + harness.run(); + + let (action, _outcome) = last_result(&harness); + assert!( + matches!(action, AppAction::AddScreen(Screen::WithdrawalScreen(_))), + "expected Send to wallet to open WithdrawalScreen, got {action:?}" + ); + }); +} + +/// Clicking `Send to another identity` must open the transfer screen. +#[test] +fn home_send_to_another_identity_click_opens_transfer_screen() { + with_isolated_data_dir(|| { + let (_runtime, app_context) = fresh_app_context(); + app_context.set_user_role(UserRole::Everyday); + seed_identity(&app_context, vec![key(1, Purpose::AUTHENTICATION)], vec![]); + + let mut harness = mount_home(app_context); + harness.get_by_label("Send to another identity").click(); + harness.run(); + + let (action, _outcome) = last_result(&harness); + assert!( + matches!(action, AppAction::AddScreen(Screen::TransferScreen(_))), + "expected Send to another identity to open TransferScreen, got {action:?}" + ); + }); +} + +/// `Add contact` is gated behind a social profile (design-spec §B.3). Seed +/// one via `ProfileCache::record_saved` — the same API the hub uses after a +/// real profile load — so the enabled path, and its routing to the Contacts +/// tab, is exercised without a network round-trip. +#[test] +fn home_add_contact_click_routes_to_contacts_when_profile_is_set() { + with_isolated_data_dir(|| { + let (_runtime, app_context) = fresh_app_context(); + app_context.set_user_role(UserRole::Everyday); + let identity_id = + seed_identity(&app_context, vec![key(1, Purpose::AUTHENTICATION)], vec![]); + + let mut harness = mount_home(app_context); + harness.state_mut().1.record_saved( + identity_id, + ProfileFields { + display_name: "Alex".to_string(), + ..Default::default() + }, + ); + harness.run(); + + let button = harness.get_by_label("Add contact"); + assert!( + !button.accesskit_node().is_disabled(), + "Add contact must enable once the identity has a social profile" + ); + button.click(); + harness.run(); + + let (_action, outcome) = last_result(&harness); + assert_eq!( + *outcome, + HomeOutcome::GoToContacts, + "expected Add contact to route to the Contacts tab, got {outcome:?}" + ); + }); +} diff --git a/tests/kittest/main.rs b/tests/kittest/main.rs index 09fa4c25a..f3d2a9952 100644 --- a/tests/kittest/main.rs +++ b/tests/kittest/main.rs @@ -5,6 +5,7 @@ mod create_asset_lock_screen; mod dashpay_screen; mod global_nav_switcher; mod identities_screen; +mod identity_home; mod identity_hub; mod identity_hub_activity; mod identity_hub_contacts; diff --git a/tests/kittest/withdraw_screen.rs b/tests/kittest/withdraw_screen.rs index b508cd228..ea40c7e4a 100644 --- a/tests/kittest/withdraw_screen.rs +++ b/tests/kittest/withdraw_screen.rs @@ -170,7 +170,7 @@ fn ghost_transfer_key_shows_no_keys_empty_state_not_a_form() { assert!( harness - .query_by_label_contains("You do not have any withdrawal keys loaded") + .query_by_label_contains("This identity has no loaded key") .is_some(), "a ghost-key-only identity must show the no-keys empty state" ); @@ -241,7 +241,7 @@ fn ghost_key_construction_does_not_leak_raw_error_banner() { let harness = mount_withdrawal_screen(screen); assert!( harness - .query_by_label_contains("You do not have any withdrawal keys loaded") + .query_by_label_contains("This identity has no loaded key") .is_some(), "the no-keys empty state must still render after the banner-leak fix" ); @@ -288,7 +288,7 @@ fn private_backed_transfer_key_is_selected_and_rendered_in_combo() { ); assert!( harness - .query_by_label_contains("You do not have any withdrawal keys loaded") + .query_by_label_contains("This identity has no loaded key") .is_none() );