From 51e74f9b9f35242182e8174f8a2b502fd7765a1c Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 17 Jul 2026 09:32:42 +0000 Subject: [PATCH 1/4] fix(feature-gate): activate shielded capability at protocol v12 Shielded state transitions are live on mainnet as of upstream protocol v12 (rs-platform-version::SHIELDED_POOL_INITIAL_PROTOCOL_VERSION). Point the capability gate at that constant instead of the placeholder `None` set while the feature was unshipped, and replace the tripwire test with real coverage of the v12 activation boundary across roles. Co-Authored-By: Codex Sol Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 5 +++ src/context/feature_gate.rs | 71 +++++++++++++++++++++---------------- 2 files changed, 46 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e66aebf37..53eb93969 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Changed +- **Shielded transactions are available on supported networks**: sending, + receiving, shielding, and unshielding are enabled when the connected network's + protocol version supports them, including mainnet. These operations were + previously gated off everywhere pending upstream activation. + - **The first launch after an upgrade asks for each password-protected wallet's password**: the app moves your wallets into a new storage format on that first launch, and it needs each protected wallet's password to finish the move for diff --git a/src/context/feature_gate.rs b/src/context/feature_gate.rs index 5ee38cb1a..972a5599c 100644 --- a/src/context/feature_gate.rs +++ b/src/context/feature_gate.rs @@ -1,21 +1,15 @@ use crate::context::AppContext; use crate::model::user_role::UserRole; +use dash_sdk::dpp::version::feature_initial_protocol_versions::SHIELDED_POOL_INITIAL_PROTOCOL_VERSION; -/// The platform protocol version that first defines the shielded state -/// transitions (shield, shielded transfer, unshield, shield from asset lock, -/// shielded withdrawal) — `None` while they remain unshipped, which is the case -/// on every released version today, so [`Capability::ShieldedProtocol`] is unmet -/// on every network. +/// Shielded state transitions activate at upstream rs-platform-version's +/// [`SHIELDED_POOL_INITIAL_PROTOCOL_VERSION`] (protocol v12), sourced directly +/// so DET stays aligned if upstream renumbers the feature. /// -/// TODO: set this to the activation version when upstream ships the shielded state -/// transitions, then re-check the shielded gates (the tripwire test below fails -/// until they are). Do NOT infer activation from -/// `FeatureVersionBounds::max_version > 0` instead: `check_version` is -/// `v >= min && v <= max`, so `{min: 0, max: 0}` is a legitimately checkable v0 -/// bound — `identity_create_state_transition`, a live feature, ships exactly that -/// triple — not an "undefined" marker. A shielded transition released at v0 would -/// read as permanently absent. See the PR #879 review. -const SHIELDED_ACTIVATION_PROTOCOL_VERSION: Option = None; +/// Do not infer activation from `FeatureVersionBounds::max_version > 0`: +/// `{ min: 0, max: 0 }` is a valid v0 bound, not an undefined marker. +const SHIELDED_ACTIVATION_PROTOCOL_VERSION: Option = + Some(SHIELDED_POOL_INITIAL_PROTOCOL_VERSION); /// A runtime capability of the connected platform, evaluated against the live /// context. Independent of the user's role — it answers "does the connected @@ -38,7 +32,7 @@ impl Capability { fn is_met(self, ctx: &AppContext) -> bool { match self { Capability::ShieldedProtocol => match SHIELDED_ACTIVATION_PROTOCOL_VERSION { - // Not shipped anywhere yet, so no network can offer it. + // A closed gate makes the capability unavailable on every network. None => false, // The version fetched from the connected network, not the hardcoded // default — the capability is per-network. The boot value (0, "not @@ -303,32 +297,49 @@ mod tests { } } - /// Tripwire. Shielded state transitions have not shipped, so - /// [`SHIELDED_ACTIVATION_PROTOCOL_VERSION`] is still `None`: the capability is - /// unmet on every protocol version upstream defines, and the "capability met" - /// half of the AND cannot be exercised yet. - /// - /// This test fails the moment that constant names a version upstream actually - /// ships. That is the point: whoever activates the capability must then - /// re-check the shielded gates and add the missing - /// `capability ∧ role ⇒ available` case below. #[test] - fn no_known_protocol_version_reaches_the_shielded_activation_version() { + fn shielded_capability_tracks_the_activation_boundary() { let (_tmp, ctx) = ctx_with_role(UserRole::Developer); let versions = known_protocol_versions(); assert!(!versions.is_empty(), "the probe must find some versions"); for version in versions { ctx.set_platform_protocol_version(version); - assert!( - !Capability::ShieldedProtocol.is_met(&ctx), - "protocol v{version} now reaches the shielded activation version \ - ({SHIELDED_ACTIVATION_PROTOCOL_VERSION:?}) — the shielded gates have a \ - reachable capability and need re-checking" + assert_eq!( + Capability::ShieldedProtocol.is_met(&ctx), + version >= SHIELDED_POOL_INITIAL_PROTOCOL_VERSION, + "shielded capability on protocol v{version}" ); } } + #[test] + fn shielded_operations_are_available_at_activation_for_developer() { + let (_tmp, ctx) = ctx_with_role(UserRole::Developer); + ctx.set_platform_protocol_version(SHIELDED_POOL_INITIAL_PROTOCOL_VERSION); + + assert!(FeatureGate::ShieldedOperations.is_available(&ctx)); + } + + #[test] + fn shielded_operations_are_unavailable_before_activation_for_developer() { + let (_tmp, ctx) = ctx_with_role(UserRole::Developer); + let version = SHIELDED_POOL_INITIAL_PROTOCOL_VERSION + .checked_sub(1) + .expect("shielded activation must follow the boot protocol version"); + ctx.set_platform_protocol_version(version); + + assert!(!FeatureGate::ShieldedOperations.is_available(&ctx)); + } + + #[test] + fn shielded_operations_are_unavailable_at_activation_for_everyday_user() { + let (_tmp, ctx) = ctx_with_role(UserRole::Everyday); + ctx.set_platform_protocol_version(SHIELDED_POOL_INITIAL_PROTOCOL_VERSION); + + assert!(!FeatureGate::ShieldedOperations.is_available(&ctx)); + } + /// AND semantics. `ShieldedOperations` is the first multi-check gate: it needs /// the experimental axis *and* the network capability. A Developer passes the /// experimental check outright, so the gate can only be closed by the failing From 3a0a6cce588b8dbec9068532c1aa968914a34f55 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:12:42 +0000 Subject: [PATCH 2/4] fix(ui): distinguish network vs role reason in shielded-unavailable notice --- CHANGELOG.md | 6 +++++ src/context/feature_gate.rs | 37 ++++++++++++++++++++++++++++ src/ui/wallets/shielded_tab.rs | 44 +++++++++++++++++++++++++--------- 3 files changed, 76 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53eb93969..decf5ecad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). interactive there, and adding it to the remaining screens, is tracked as a follow-up. +### Fixed + +- **Shielded availability notice**: now distinguishes when the connected network + does not support shielded sending from when the current interface mode does + not unlock it. + ### Changed - **Shielded transactions are available on supported networks**: sending, diff --git a/src/context/feature_gate.rs b/src/context/feature_gate.rs index 972a5599c..ed0ab81eb 100644 --- a/src/context/feature_gate.rs +++ b/src/context/feature_gate.rs @@ -158,6 +158,16 @@ impl FeatureGate { pub fn is_available(self, ctx: &AppContext) -> bool { self.checks().iter().all(|c| c.is_met(ctx)) } + + /// The first check that fails to hold for this gate, or `None` if every + /// check passes (equivalent to `is_available` returning `true`). Lets a + /// caller surface which axis is actually blocking availability — e.g. + /// distinguishing "the network doesn't support this yet" (a + /// [`Check::Capability`]) from "your interface mode doesn't unlock this" (a + /// [`Check::Experimental`]) — instead of a single opaque `false`. + pub fn first_unmet_check(self, ctx: &AppContext) -> Option { + self.checks().iter().copied().find(|c| !c.is_met(ctx)) + } } #[cfg(test)] @@ -340,6 +350,33 @@ mod tests { assert!(!FeatureGate::ShieldedOperations.is_available(&ctx)); } + #[test] + fn shielded_operations_reports_the_first_unmet_check() { + let (_tmp, ctx) = ctx_with_role(UserRole::Developer); + let version = SHIELDED_POOL_INITIAL_PROTOCOL_VERSION + .checked_sub(1) + .expect("shielded activation must follow the boot protocol version"); + ctx.set_platform_protocol_version(version); + assert_eq!( + FeatureGate::ShieldedOperations.first_unmet_check(&ctx), + Some(Check::Capability(Capability::ShieldedProtocol)) + ); + + let (_tmp, ctx) = ctx_with_role(UserRole::Everyday); + ctx.set_platform_protocol_version(SHIELDED_POOL_INITIAL_PROTOCOL_VERSION); + assert_eq!( + FeatureGate::ShieldedOperations.first_unmet_check(&ctx), + Some(Check::Experimental(ExperimentalFeature::Shielded)) + ); + + let (_tmp, ctx) = ctx_with_role(UserRole::Developer); + ctx.set_platform_protocol_version(SHIELDED_POOL_INITIAL_PROTOCOL_VERSION); + assert_eq!( + FeatureGate::ShieldedOperations.first_unmet_check(&ctx), + None + ); + } + /// AND semantics. `ShieldedOperations` is the first multi-check gate: it needs /// the experimental axis *and* the network capability. A Developer passes the /// experimental check outright, so the gate can only be closed by the failing diff --git a/src/ui/wallets/shielded_tab.rs b/src/ui/wallets/shielded_tab.rs index a34c3c3a3..a4de76ab0 100644 --- a/src/ui/wallets/shielded_tab.rs +++ b/src/ui/wallets/shielded_tab.rs @@ -2,7 +2,7 @@ use crate::app::AppAction; use crate::backend_task::BackendTask; use crate::backend_task::migration::MigrationTask; use crate::context::AppContext; -use crate::context::feature_gate::FeatureGate; +use crate::context::feature_gate::{Check, FeatureGate}; use crate::context::migration_status::{MigrationState, MigrationStep}; use crate::model::address::truncate_address; use crate::model::fee_estimation::format_credits_as_dash; @@ -45,9 +45,15 @@ pub const SHIELDED_MIGRATION_ERROR_LABEL: &str = pub const SHIELDED_TAB_SKIPPED_LABEL: &str = "Shielded features are paused until the next launch. Restart the app to retry the migration."; /// Shown in place of the Shield / Send / Unshield controls when the connected -/// network does not yet support shielded operations. Viewing balance, address, -/// and notes stays available. -pub const SHIELDED_OPERATIONS_UNAVAILABLE_LABEL: &str = "Shielded sending is not available on this network yet. You can still view your shielded balance and receive address."; +/// network does not yet support shielded state transitions. Viewing balance, +/// address, and notes stays available. +pub const SHIELDED_OPERATIONS_NETWORK_UNAVAILABLE_LABEL: &str = "Shielded sending is not available on this network yet. You can still view your shielded balance and receive address."; +/// Shown in place of the Shield / Send / Unshield controls when the connected +/// network supports shielded state transitions but the user's interface mode +/// does not unlock them yet. Viewing balance, address, and notes stays +/// available. +// Keep "Expert view" aligned with the experimental threshold if it changes. +pub const SHIELDED_OPERATIONS_ROLE_UNAVAILABLE_LABEL: &str = "Shielded sending needs Expert view or higher. You can still view your shielded balance and receive address. Switch your interface mode in Settings to use it."; /// J-3 indicator state. Derived purely from [`MigrationState`] and the /// session-local "skip" flag, so the same inputs always yield the same @@ -710,8 +716,12 @@ impl ShieldedTabView { }); } } else { + let label = match FeatureGate::ShieldedOperations.first_unmet_check(&self.app_context) { + Some(Check::Experimental(_)) => SHIELDED_OPERATIONS_ROLE_UNAVAILABLE_LABEL, + _ => SHIELDED_OPERATIONS_NETWORK_UNAVAILABLE_LABEL, + }; ui.label( - RichText::new(SHIELDED_OPERATIONS_UNAVAILABLE_LABEL) + RichText::new(label) .size(12.0) .color(DashColors::text_secondary(dark_mode)), ); @@ -843,16 +853,28 @@ mod tests { ); } - /// The notice shown when shielded operations are unavailable is i18n-clean - /// (a complete sentence) and tells the user what they can still do, so the - /// gated-off action controls never read as a dead end. + /// The network notice is complete and says what remains available. + #[test] + fn network_unavailable_label_is_i18n_clean() { + assert!(SHIELDED_OPERATIONS_NETWORK_UNAVAILABLE_LABEL.ends_with('.')); + assert!( + SHIELDED_OPERATIONS_NETWORK_UNAVAILABLE_LABEL.contains("view"), + "the notice must state what the user can still do" + ); + } + + /// The role notice is complete, actionable, and names the required tier. #[test] - fn operations_unavailable_label_is_i18n_clean() { - assert!(SHIELDED_OPERATIONS_UNAVAILABLE_LABEL.ends_with('.')); + fn role_unavailable_label_is_i18n_clean() { + assert!(SHIELDED_OPERATIONS_ROLE_UNAVAILABLE_LABEL.ends_with('.')); assert!( - SHIELDED_OPERATIONS_UNAVAILABLE_LABEL.contains("view"), + SHIELDED_OPERATIONS_ROLE_UNAVAILABLE_LABEL.contains("view"), "the notice must state what the user can still do" ); + assert!( + SHIELDED_OPERATIONS_ROLE_UNAVAILABLE_LABEL.contains("Expert view"), + "the notice must name the interface mode that unlocks shielded sending" + ); } /// The Verified badge follows the same icon + text rule so From ecf98742cb5a2c1ddc86b4abf0400ebb3c72f068 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 17 Jul 2026 15:44:47 +0000 Subject: [PATCH 3/4] fix(shielded): distinguish backend gate failures Preserve the first unmet shielded feature-gate check through both backend refusal layers, and keep the UI classification single-evaluation and exhaustive. Co-Authored-By: OpenAI Codex --- src/backend_task/error.rs | 15 ++- src/backend_task/mod.rs | 20 ++-- src/backend_task/shielded/mod.rs | 54 +++++++++- src/context/feature_gate.rs | 13 ++- src/ui/wallets/shielded_tab.rs | 178 ++++++++++++++++--------------- 5 files changed, 172 insertions(+), 108 deletions(-) diff --git a/src/backend_task/error.rs b/src/backend_task/error.rs index a11e92e26..aa4f8b6cd 100644 --- a/src/backend_task/error.rs +++ b/src/backend_task/error.rs @@ -1856,12 +1856,19 @@ pub enum TaskError { // ────────────────────────────────────────────────────────────────────────── // Shielded pool errors // ────────────────────────────────────────────────────────────────────────── - /// A fund-moving shielded operation was requested while the shielded - /// operations feature gate was closed. + /// A fund-moving shielded operation was requested on a network that does + /// not support shielded state transitions. #[error( - "Shielding, sending, or withdrawing shielded funds is not available right now. Use a regular payment instead, or try again after a future update." + "Shielded operations are not available on this network yet. Use a regular payment instead, or try again after a future network update." )] - ShieldedOperationsUnavailable, + ShieldedOperationsNetworkUnavailable, + + /// A fund-moving shielded operation was requested from an interface mode + /// that does not unlock experimental features. + #[error( + "Shielded operations need Expert view or higher. Switch your interface mode in Settings to use them." + )] + ShieldedOperationsRoleUnavailable, /// No unspent shielded notes are available. #[error("You have no shielded funds available. Please shield some credits first.")] diff --git a/src/backend_task/mod.rs b/src/backend_task/mod.rs index e2565b284..cc3dbee62 100644 --- a/src/backend_task/mod.rs +++ b/src/backend_task/mod.rs @@ -10,7 +10,6 @@ use crate::backend_task::platform_info::{PlatformInfoTaskRequestType, PlatformIn use crate::backend_task::system_task::SystemTask; use crate::backend_task::wallet::WalletTask; use crate::context::AppContext; -use crate::context::feature_gate::FeatureGate; use crate::context::identity_load_registry::IdentityLoadToken; use crate::model::masternode_input::decode_identity_id; use dash_sdk::dpp::address_funds::PlatformAddress; @@ -746,9 +745,9 @@ impl AppContext { // lock/await/secret), so it is safe to call before backend init. The // in-handler gate in `run_shielded_task` stays as the authoritative check. if let BackendTask::ShieldedTask(_) = &task - && !FeatureGate::ShieldedOperations.is_available(self) + && let Some(error) = shielded::shielded_operations_unavailable_error(self) { - return Err(TaskError::ShieldedOperationsUnavailable); + return Err(error); } let _contact_request_claim = match dashpay_request_id(&task) { @@ -1060,6 +1059,7 @@ impl AppContext { #[cfg(test)] mod tests { use super::*; + use crate::context::feature_gate::FeatureGate; #[test] fn backend_task_context_preserves_document_query_and_fetch_kind() { @@ -1268,11 +1268,9 @@ mod tests { } } - /// The shielded pre-check runs before the migration gate, so an *unavailable* - /// shielded write is refused with `ShieldedOperationsUnavailable` even while a - /// storage update collects wallet passwords — the accurate, actionable message - /// ("shielded is not available") rather than the misleading "wait for the - /// update", since waiting will never make shielded available. + /// The shielded pre-check runs before the migration gate, so a role-gated + /// shielded write reports the interface mode needed to unlock it even while a + /// storage update collects wallet passwords. /// /// The migration gate for shielded still applies once shielded operations /// ship (the pre-check passes, then the gate short-circuits); its @@ -1285,9 +1283,11 @@ mod tests { use crate::backend_task::shielded::ShieldedTask; use crate::context::migration_status::MigrationState; use crate::context::test_support::test_app_context; + use dash_sdk::dpp::version::feature_initial_protocol_versions::SHIELDED_POOL_INITIAL_PROTOCOL_VERSION; let tmp = tempfile::tempdir().expect("tempdir"); let ctx = test_app_context(tmp.path()); + ctx.set_platform_protocol_version(SHIELDED_POOL_INITIAL_PROTOCOL_VERSION); assert!(!FeatureGate::ShieldedOperations.is_available(&ctx)); let (tx, _rx) = tokio::sync::mpsc::channel::(32); let sender = SenderAsync::new(tx, ctx.egui_ctx().clone()); @@ -1308,8 +1308,8 @@ mod tests { ) .await; assert!( - matches!(result, Err(TaskError::ShieldedOperationsUnavailable)), - "the shielded pre-check must refuse an unavailable write before the migration gate, got {result:?}", + matches!(result, Err(TaskError::ShieldedOperationsRoleUnavailable)), + "the shielded pre-check must report the role gate before the migration gate, got {result:?}", ); if let Ok(backend) = ctx.wallet_backend() { diff --git a/src/backend_task/shielded/mod.rs b/src/backend_task/shielded/mod.rs index f04a1bdbf..4364b3057 100644 --- a/src/backend_task/shielded/mod.rs +++ b/src/backend_task/shielded/mod.rs @@ -1,7 +1,7 @@ use crate::backend_task::BackendTaskSuccessResult; use crate::backend_task::error::TaskError; use crate::context::AppContext; -use crate::context::feature_gate::FeatureGate; +use crate::context::feature_gate::{Check, FeatureGate}; use crate::model::wallet::WalletSeedHash; use crate::wallet_backend::PlatformPathIndex; use dash_sdk::dpp::address_funds::{OrchardAddress, PlatformAddress}; @@ -55,6 +55,16 @@ pub enum ShieldedTask { }, } +pub(super) fn shielded_operations_unavailable_error(ctx: &AppContext) -> Option { + match FeatureGate::ShieldedOperations.first_unmet_check(ctx) { + None => None, + Some(Check::Capability(_)) => Some(TaskError::ShieldedOperationsNetworkUnavailable), + Some(Check::MinRole(_) | Check::Experimental(_)) => { + Some(TaskError::ShieldedOperationsRoleUnavailable) + } + } +} + impl AppContext { /// Run a shielded-pool task by forwarding to the upstream coordinator /// through the [`WalletBackend`](crate::wallet_backend::WalletBackend) @@ -70,11 +80,12 @@ impl AppContext { self: &Arc, task: ShieldedTask, ) -> Result { - if !FeatureGate::ShieldedOperations.is_available(self) { + if let Some(error) = shielded_operations_unavailable_error(self) { tracing::warn!( + ?error, "Refused a shielded fund movement because shielded operations are unavailable" ); - return Err(TaskError::ShieldedOperationsUnavailable); + return Err(error); } let backend = self.wallet_backend()?; @@ -249,11 +260,41 @@ mod tests { let result = ctx.run_backend_task(task, sender).await; assert!( - matches!(&result, Err(TaskError::ShieldedOperationsUnavailable)), + matches!( + &result, + Err(TaskError::ShieldedOperationsNetworkUnavailable) + ), "a direct backend dispatch must reject unsupported shielded writes before moving funds: {result:?}" ); } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn shielded_handler_reports_role_gate_before_touching_the_backend() { + use dash_sdk::dpp::version::feature_initial_protocol_versions::SHIELDED_POOL_INITIAL_PROTOCOL_VERSION; + + let tmp = tempfile::tempdir().expect("tempdir"); + let ctx = test_app_context(tmp.path()); + ctx.set_platform_protocol_version(SHIELDED_POOL_INITIAL_PROTOCOL_VERSION); + assert!(!FeatureGate::ShieldedOperations.is_available(&ctx)); + assert!(ctx.wallet_backend().is_err(), "precondition"); + + let result = ctx + .run_shielded_task(ShieldedTask::ShieldFromAssetLock { + seed_hash: WalletSeedHash::default(), + amount_duffs: 1, + }) + .await; + + assert!( + matches!(&result, Err(TaskError::ShieldedOperationsRoleUnavailable)), + "the handler must explain that the user's role blocks shielded operations: {result:?}" + ); + assert!( + ctx.wallet_backend().is_err(), + "the role gate must return before the handler touches the wallet backend" + ); + } + /// The shielded pre-check in `run_backend_task` refuses an unavailable /// shielded write *before* `ensure_wallet_backend` wires the backend — which /// would otherwise materialize seeds, register upstream, and bind Orchard for @@ -280,7 +321,10 @@ mod tests { let result = ctx.run_backend_task(task, sender).await; assert!( - matches!(&result, Err(TaskError::ShieldedOperationsUnavailable)), + matches!( + &result, + Err(TaskError::ShieldedOperationsNetworkUnavailable) + ), "the pre-check must reject the shielded write: {result:?}" ); assert!( diff --git a/src/context/feature_gate.rs b/src/context/feature_gate.rs index ed0ab81eb..51d0e8eea 100644 --- a/src/context/feature_gate.rs +++ b/src/context/feature_gate.rs @@ -324,11 +324,16 @@ mod tests { } #[test] - fn shielded_operations_are_available_at_activation_for_developer() { - let (_tmp, ctx) = ctx_with_role(UserRole::Developer); - ctx.set_platform_protocol_version(SHIELDED_POOL_INITIAL_PROTOCOL_VERSION); + fn shielded_operations_are_available_at_activation_for_unlocked_roles() { + for role in [UserRole::Power, UserRole::Developer] { + let (_tmp, ctx) = ctx_with_role(role); + ctx.set_platform_protocol_version(SHIELDED_POOL_INITIAL_PROTOCOL_VERSION); - assert!(FeatureGate::ShieldedOperations.is_available(&ctx)); + assert!( + FeatureGate::ShieldedOperations.is_available(&ctx), + "shielded operations must be available at activation for {role:?}" + ); + } } #[test] diff --git a/src/ui/wallets/shielded_tab.rs b/src/ui/wallets/shielded_tab.rs index a4de76ab0..0b34299cf 100644 --- a/src/ui/wallets/shielded_tab.rs +++ b/src/ui/wallets/shielded_tab.rs @@ -634,97 +634,105 @@ impl ShieldedTabView { // network must be able to settle. Where shielded operations are // unavailable, hide the action controls (balance, address, and notes // stay visible) rather than offer a dead end the backend would reject. - if FeatureGate::ShieldedOperations.is_available(&self.app_context) { - // J-3 spend lock: any verifying / failed indicator pauses spends - // regardless of the local sync state. Computed once so the - // hover-text and the "Spending paused" notice agree. - let spend_locked = matches!( - indicator, - ShieldedIndicator::Verifying | ShieldedIndicator::Failed - ); - - // Action buttons - ui.horizontal(|ui| { - let shield_btn = - egui::Button::new(RichText::new("Shield").color(Color32::WHITE).size(14.0)) - .fill(DashColors::DASH_BLUE); - if ui - .add_enabled(!self.syncing && !spend_locked, shield_btn) - .on_hover_text(if spend_locked { - SHIELDED_SPEND_LOCKED_TOOLTIP - } else { - "Shield funds from a platform or core address into the shielded pool" - }) - .clicked() - { - action |= self.open_send_flow(SendFlow::Shield); - } + match FeatureGate::ShieldedOperations.first_unmet_check(&self.app_context) { + None => { + // J-3 spend lock: any verifying / failed indicator pauses spends + // regardless of the local sync state. Computed once so the + // hover-text and the "Spending paused" notice agree. + let spend_locked = matches!( + indicator, + ShieldedIndicator::Verifying | ShieldedIndicator::Failed + ); - let can_spend = - !self.syncing && self.tree_synced && self.shielded_balance > 0 && !spend_locked; + // Action buttons + ui.horizontal(|ui| { + let shield_btn = + egui::Button::new(RichText::new("Shield").color(Color32::WHITE).size(14.0)) + .fill(DashColors::DASH_BLUE); + if ui + .add_enabled(!self.syncing && !spend_locked, shield_btn) + .on_hover_text(if spend_locked { + SHIELDED_SPEND_LOCKED_TOOLTIP + } else { + "Shield funds from a platform or core address into the shielded pool" + }) + .clicked() + { + action |= self.open_send_flow(SendFlow::Shield); + } - let send_btn = egui::Button::new( - RichText::new("Send (Private)") - .color(Color32::WHITE) - .size(14.0), - ) - .fill(DashColors::DASH_BLUE); - if ui - .add_enabled(can_spend, send_btn) - .on_hover_text(if spend_locked { - SHIELDED_SPEND_LOCKED_TOOLTIP - } else if self.tree_synced { - "Transfer privately within the shielded pool" - } else { - "Sync notes first to enable spending" - }) - .clicked() - { - action |= self.open_send_flow(SendFlow::ShieldedSend); - } + let can_spend = !self.syncing + && self.tree_synced + && self.shielded_balance > 0 + && !spend_locked; - let unshield_btn = - egui::Button::new(RichText::new("Unshield").color(Color32::WHITE).size(14.0)) - .fill(DashColors::DASH_BLUE); - if ui - .add_enabled(can_spend, unshield_btn) - .on_hover_text(if spend_locked { - SHIELDED_SPEND_LOCKED_TOOLTIP - } else if self.tree_synced { - "Unshield credits to a platform address" - } else { - "Sync notes first to enable spending" - }) - .clicked() - { - action |= self.open_send_flow(SendFlow::Unshield); - } - }); + let send_btn = egui::Button::new( + RichText::new("Send (Private)") + .color(Color32::WHITE) + .size(14.0), + ) + .fill(DashColors::DASH_BLUE); + if ui + .add_enabled(can_spend, send_btn) + .on_hover_text(if spend_locked { + SHIELDED_SPEND_LOCKED_TOOLTIP + } else if self.tree_synced { + "Transfer privately within the shielded pool" + } else { + "Sync notes first to enable spending" + }) + .clicked() + { + action |= self.open_send_flow(SendFlow::ShieldedSend); + } - // J-3 "Spending paused" row — icon + text per TC-A11Y-006 so - // colour-blind / greyscale users get the same signal as the - // disabled-button affordance. - if spend_locked { - ui.add_space(2.0); - ui.horizontal(|ui| { - ui.label(RichText::new(SHIELDED_LOCK_ICON)); - ui.label( - RichText::new(SHIELDED_SPEND_LOCKED_LABEL) - .size(12.0) - .color(DashColors::text_secondary(dark_mode)), - ); + let unshield_btn = egui::Button::new( + RichText::new("Unshield").color(Color32::WHITE).size(14.0), + ) + .fill(DashColors::DASH_BLUE); + if ui + .add_enabled(can_spend, unshield_btn) + .on_hover_text(if spend_locked { + SHIELDED_SPEND_LOCKED_TOOLTIP + } else if self.tree_synced { + "Unshield credits to a platform address" + } else { + "Sync notes first to enable spending" + }) + .clicked() + { + action |= self.open_send_flow(SendFlow::Unshield); + } }); + + // J-3 "Spending paused" row — icon + text per TC-A11Y-006 so + // colour-blind / greyscale users get the same signal as the + // disabled-button affordance. + if spend_locked { + ui.add_space(2.0); + ui.horizontal(|ui| { + ui.label(RichText::new(SHIELDED_LOCK_ICON)); + ui.label( + RichText::new(SHIELDED_SPEND_LOCKED_LABEL) + .size(12.0) + .color(DashColors::text_secondary(dark_mode)), + ); + }); + } + } + Some(check) => { + let label = match check { + Check::Capability(_) => SHIELDED_OPERATIONS_NETWORK_UNAVAILABLE_LABEL, + Check::MinRole(_) | Check::Experimental(_) => { + SHIELDED_OPERATIONS_ROLE_UNAVAILABLE_LABEL + } + }; + ui.label( + RichText::new(label) + .size(12.0) + .color(DashColors::text_secondary(dark_mode)), + ); } - } else { - let label = match FeatureGate::ShieldedOperations.first_unmet_check(&self.app_context) { - Some(Check::Experimental(_)) => SHIELDED_OPERATIONS_ROLE_UNAVAILABLE_LABEL, - _ => SHIELDED_OPERATIONS_NETWORK_UNAVAILABLE_LABEL, - }; - ui.label( - RichText::new(label) - .size(12.0) - .color(DashColors::text_secondary(dark_mode)), - ); } ui.add_space(15.0); From 310293722f56c74ba0b1b4956b8fae7638b3d438 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 21 Jul 2026 01:22:21 +0000 Subject: [PATCH 4/4] fix(shielded): refresh protocol version in headless MCP/CLI flows The shielded-operations feature gate depends on platform_protocol_version, which was only ever populated by the GUI reconciler on a Synced-state edge. Headless MCP/CLI callers never triggered that fetch, so the version stayed at its zero boot value forever and shielded operations stayed refused even on a live mainnet running protocol v12. ensure_spv_synced now fetches/refreshes CurrentEpochInfo when the protocol version is unpopulated, with retry on failure, and every shielded fund-moving MCP tool requires fresh protocol metadata before dispatch. Also fixes the backend-e2e preflight helper to check the correct FeatureGate variant (ShieldedOperations, not the always-true Shielded gate) and adds the missing protocol/role acceptance-criteria axis to the SND-007/009/010/015/016 user stories. Co-Authored-By: Codex GPT-5 --- docs/user-stories.md | 8 +- src/mcp/resolve.rs | 161 +++++++++++++++++- src/mcp/tools/shielded.rs | 14 +- tests/backend-e2e/framework/harness.rs | 11 +- .../backend-e2e/framework/shielded_helpers.rs | 4 +- 5 files changed, 176 insertions(+), 22 deletions(-) diff --git a/docs/user-stories.md b/docs/user-stories.md index c2fccf397..bf39d996b 100644 --- a/docs/user-stories.md +++ b/docs/user-stories.md @@ -373,7 +373,7 @@ As a developer, I want to shield DASH directly from my Core wallet so that I can - Select Core Wallet source and enter a shielded address as destination. - System creates an asset lock, waits for proof, and shields the credits. - Progress banner shows multi-step operation status. -- Developer mode required. +- Available only on Platform protocol v12 or later when Expert view or Developer view is selected. ### SND-008: Top up identity from Send screen [Implemented] **Persona:** Priya, Jordan @@ -391,7 +391,7 @@ As a developer, I want to shield credits from a Platform address into the shield - Select Platform Addresses as source and enter a shielded address as destination. - System auto-selects the highest-balance Platform address. -- Developer mode required. +- Available only on Platform protocol v12 or later when Expert view or Developer view is selected. ### SND-010: Withdraw from shielded pool to Core address [Implemented] **Persona:** Jordan @@ -399,7 +399,7 @@ As a developer, I want to shield credits from a Platform address into the shield As a developer, I want to withdraw from the shielded pool directly to a Core address so that I can convert shielded credits back to spendable DASH. - Select Shielded Pool as source and enter a Core address as destination. -- Developer mode required. +- Available only on Platform protocol v12 or later when Expert view or Developer view is selected. ### SND-011: Transfer identity credits to another identity [Implemented] **Persona:** Priya, Jordan @@ -443,6 +443,7 @@ As a developer, I want to move credits out of the shielded pool to one of my Pla - Select Shielded Pool as source and enter a Platform address as destination. - Reachable from the Shielded tab's "Unshield" button, which opens the unified Send screen preset for this flow. - The shielded balance decreases and the Platform address balance increases after the operation completes. +- Available only on Platform protocol v12 or later when Expert view or Developer view is selected. ### SND-016: Send privately within the shielded pool [Implemented] **Persona:** Jordan @@ -452,6 +453,7 @@ As a developer, I want to transfer credits privately from my shielded pool to an - Select Shielded Pool as source and enter a shielded address as destination. - Reachable from the Shielded tab's "Send (Private)" button, which opens the unified Send screen preset for this flow. - Spending is paused until the shielded balance is verified, and the button is disabled with a clear reason while verification is in progress. +- Available only on Platform protocol v12 or later when Expert view or Developer view is selected. --- diff --git a/src/mcp/resolve.rs b/src/mcp/resolve.rs index 9af4a3448..b3f8e2668 100644 --- a/src/mcp/resolve.rs +++ b/src/mcp/resolve.rs @@ -226,6 +226,10 @@ async fn ensure_legacy_storage_migrated(ctx: &Arc) -> Result<(), Mcp /// `WalletStorageNotReady` fast-fail that `run_backend_task` applies while /// migration is mid-flight. /// +/// Once synced, an unpopulated Platform protocol cache is refreshed so +/// headless feature gates evaluate the connected network rather than boot state. +/// Refresh failures remain best-effort here so Core-only tools can proceed. +/// /// ## Why `SpvStatus::Running`, not `OverallConnectionState::Synced` /// /// `OverallConnectionState::Synced` requires both SPV running **and** @@ -237,6 +241,26 @@ async fn ensure_legacy_storage_migrated(ctx: &Arc) -> Result<(), Mcp /// proof-verifying SDK calls only require a synced chain, not a live DAPI /// counter at the `ensure_spv_synced` callsite. pub(crate) async fn ensure_spv_synced(ctx: &Arc) -> Result<(), McpToolError> { + ensure_spv_ready(ctx, ProtocolRefresh::BestEffortIfUnpopulated).await +} + +/// Require synced SPV and fresh epoch metadata before a shielded fund movement. +pub(crate) async fn ensure_shielded_operations_ready( + ctx: &Arc, +) -> Result<(), McpToolError> { + ensure_spv_ready(ctx, ProtocolRefresh::Required).await +} + +#[derive(Clone, Copy)] +enum ProtocolRefresh { + BestEffortIfUnpopulated, + Required, +} + +async fn ensure_spv_ready( + ctx: &Arc, + protocol_refresh: ProtocolRefresh, +) -> Result<(), McpToolError> { // A throwaway `TaskResult` sender: MCP/CLI has no GUI event loop consuming // it, so the receiver is dropped. The `EventBridge` only does non-blocking // `try_send`, so a closed channel is harmless. Mirrors `dispatch::dispatch_task`. @@ -249,11 +273,15 @@ pub(crate) async fn ensure_spv_synced(ctx: &Arc) -> Result<(), McpTo ensure_legacy_storage_migrated(ctx).await?; - // Subscribe BEFORE reading the current value so no transition is lost - // between the `ensure_wallet_backend_and_start_spv` call above and the - // first `borrow_and_update` below. borrow_and_update marks the current - // value "seen", so the loop never spins — each iteration always sleeps on - // a real change. + wait_for_spv_and_refresh_platform_info(ctx, protocol_refresh).await +} + +async fn wait_for_spv_and_refresh_platform_info( + ctx: &Arc, + protocol_refresh: ProtocolRefresh, +) -> Result<(), McpToolError> { + // Subscribe before reading the current value so no transition is lost. + // `borrow_and_update` keeps later iterations asleep until a real change. let mut rx = ctx.connection_status().subscribe_spv_status(); let wait = async { @@ -274,16 +302,49 @@ pub(crate) async fn ensure_spv_synced(ctx: &Arc) -> Result<(), McpTo }; match tokio::time::timeout(SPV_WAIT_TIMEOUT, wait).await { - Ok(result) => result, + Ok(result) => result?, Err(_elapsed) => { tracing::warn!( "SPV sync timed out after {} seconds (status: {:?})", SPV_WAIT_TIMEOUT.as_secs(), ctx.connection_status().spv_status() ); - Err(McpToolError::SpvSyncFailed) + return Err(McpToolError::SpvSyncFailed); } } + + match protocol_refresh { + ProtocolRefresh::BestEffortIfUnpopulated if ctx.platform_protocol_version() == 0 => { + let _ = refresh_platform_protocol_version(ctx).await; + Ok(()) + } + ProtocolRefresh::BestEffortIfUnpopulated => Ok(()), + ProtocolRefresh::Required => refresh_platform_protocol_version(ctx).await, + } +} + +async fn refresh_platform_protocol_version(ctx: &Arc) -> Result<(), McpToolError> { + tracing::trace!("Refreshing Platform epoch information for headless feature gating"); + crate::mcp::dispatch::dispatch_task( + ctx, + crate::backend_task::BackendTask::PlatformInfo( + crate::backend_task::platform_info::PlatformInfoTaskRequestType::CurrentEpochInfo, + ), + ) + .await + .map_err(|error| { + tracing::warn!( + error = ?error, + "Platform epoch information refresh failed for headless feature gating" + ); + McpToolError::TaskFailed(error) + })?; + + tracing::trace!( + protocol_version = ctx.platform_protocol_version(), + "Platform epoch information refreshed for headless feature gating" + ); + Ok(()) } /// Reject a zero send amount. `unit_label` names the JSON parameter's unit @@ -346,6 +407,92 @@ pub(crate) fn qualified_identity( #[cfg(test)] mod tests { use super::*; + use dash_sdk::Sdk; + use dash_sdk::dpp::block::extended_epoch_info::ExtendedEpochInfo; + use dash_sdk::dpp::block::extended_epoch_info::v0::ExtendedEpochInfoV0; + use dash_sdk::platform::LimitQuery; + use dash_sdk::platform::types::epoch::EpochQuery; + + async fn mock_sdk_with_current_epoch(protocol_version: u32) -> Sdk { + let epoch_info = ExtendedEpochInfo::V0(ExtendedEpochInfoV0 { + index: 42, + first_block_time: 1, + first_block_height: 2, + first_core_block_height: 3, + fee_multiplier_permille: 1_000, + protocol_version, + }); + let current_epoch_query = LimitQuery { + query: EpochQuery { + start: None, + ascending: false, + }, + limit: Some(1), + start_info: None, + }; + let mut sdk = Sdk::new_mock(); + sdk.mock() + .expect_fetch(current_epoch_query, Some(epoch_info)) + .await + .expect("register current epoch response"); + sdk + } + + #[tokio::test] + async fn headless_sync_populates_protocol_version_after_spv_is_running() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let ctx = crate::mcp::tests::legacy_wallet_context(temp_dir.path()); + let protocol_version = 12; + ctx.sdk.store(Arc::new( + mock_sdk_with_current_epoch(protocol_version).await, + )); + ctx.connection_status().set_spv_status(SpvStatus::Running); + + assert_eq!(ctx.platform_protocol_version(), 0, "boot value"); + + wait_for_spv_and_refresh_platform_info(&ctx, ProtocolRefresh::BestEffortIfUnpopulated) + .await + .expect("headless sync completion"); + + assert_eq!(ctx.platform_protocol_version(), protocol_version); + assert_eq!(ctx.fee_multiplier_permille(), 1_000); + } + + #[tokio::test] + async fn protocol_refresh_observes_activation_after_a_pre_activation_epoch() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let ctx = crate::mcp::tests::legacy_wallet_context(temp_dir.path()); + ctx.set_platform_protocol_version(11); + ctx.sdk + .store(Arc::new(mock_sdk_with_current_epoch(12).await)); + + refresh_platform_protocol_version(&ctx) + .await + .expect("refresh current epoch"); + + assert_eq!(ctx.platform_protocol_version(), 12); + } + + #[tokio::test] + async fn best_effort_protocol_refresh_retries_without_blocking_spv_readiness() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let ctx = crate::mcp::tests::legacy_wallet_context(temp_dir.path()); + ctx.sdk.store(Arc::new(Sdk::new_mock())); + ctx.connection_status().set_spv_status(SpvStatus::Running); + + wait_for_spv_and_refresh_platform_info(&ctx, ProtocolRefresh::BestEffortIfUnpopulated) + .await + .expect("SPV readiness survives a Platform metadata failure"); + assert_eq!(ctx.platform_protocol_version(), 0); + + ctx.sdk + .store(Arc::new(mock_sdk_with_current_epoch(12).await)); + wait_for_spv_and_refresh_platform_info(&ctx, ProtocolRefresh::BestEffortIfUnpopulated) + .await + .expect("SPV readiness retries Platform metadata"); + + assert_eq!(ctx.platform_protocol_version(), 12); + } #[tokio::test] async fn storage_ready_rejects_terminal_migration_failure() { diff --git a/src/mcp/tools/shielded.rs b/src/mcp/tools/shielded.rs index ed92b1518..7d7c9ebf2 100644 --- a/src/mcp/tools/shielded.rs +++ b/src/mcp/tools/shielded.rs @@ -78,7 +78,7 @@ impl AsyncTool for ShieldedShieldFromCore { resolve::ensure_wallets_hydrated(&ctx).await?; let seed_hash = resolve::wallet(&ctx, ¶m.wallet_id)?; - resolve::ensure_spv_synced(&ctx).await?; + resolve::ensure_shielded_operations_ready(&ctx).await?; let task = BackendTask::ShieldedTask(ShieldedTask::ShieldFromAssetLock { seed_hash, @@ -163,10 +163,9 @@ impl AsyncTool for ShieldedShieldFromPlatform { resolve::require_network(&ctx, Some(¶m.network))?; resolve::validate_positive_amount(param.amount_credits, "credits")?; - // INTENTIONAL: no SPV sync needed — this tool only dispatches Platform state transitions, - // not Core UTXO spends resolve::ensure_wallets_hydrated(&ctx).await?; let seed_hash = resolve::wallet(&ctx, ¶m.wallet_id)?; + resolve::ensure_shielded_operations_ready(&ctx).await?; // Pre-flight: verify the wallet's total platform balance can cover the // amount. The upstream coordinator selects the actual input addresses — @@ -271,10 +270,9 @@ impl AsyncTool for ShieldedTransferTool { let ctx = service.tool_ctx().await?; resolve::require_network(&ctx, Some(¶m.network))?; resolve::validate_positive_amount(param.amount_credits, "credits")?; - // INTENTIONAL: no SPV sync needed — this tool only dispatches Platform state transitions, - // not Core UTXO spends resolve::ensure_wallets_hydrated(&ctx).await?; let seed_hash = resolve::wallet(&ctx, ¶m.wallet_id)?; + resolve::ensure_shielded_operations_ready(&ctx).await?; let recipient_bytes = dash_sdk::dpp::address_funds::OrchardAddress::from_bech32m_string(¶m.to_address) @@ -367,10 +365,9 @@ impl AsyncTool for ShieldedUnshield { let ctx = service.tool_ctx().await?; resolve::require_network(&ctx, Some(¶m.network))?; resolve::validate_positive_amount(param.amount_credits, "credits")?; - // INTENTIONAL: no SPV sync needed — this tool only dispatches Platform state transitions, - // not Core UTXO spends resolve::ensure_wallets_hydrated(&ctx).await?; let seed_hash = resolve::wallet(&ctx, ¶m.wallet_id)?; + resolve::ensure_shielded_operations_ready(&ctx).await?; let platform_addr = dash_sdk::dpp::address_funds::PlatformAddress::from_bech32m_string(¶m.to_address) @@ -465,10 +462,9 @@ impl AsyncTool for ShieldedWithdrawTool { resolve::require_network(&ctx, Some(¶m.network))?; resolve::validate_positive_amount(param.amount_credits, "credits")?; resolve::validate_address(¶m.to_address)?; - // INTENTIONAL: no SPV sync needed — this tool dispatches a Platform state transition - // (withdrawal is queued on Platform and settles after confirmation) resolve::ensure_wallets_hydrated(&ctx).await?; let seed_hash = resolve::wallet(&ctx, ¶m.wallet_id)?; + resolve::ensure_shielded_operations_ready(&ctx).await?; let core_address = param .to_address diff --git a/tests/backend-e2e/framework/harness.rs b/tests/backend-e2e/framework/harness.rs index acecc26ec..5fcccfab6 100644 --- a/tests/backend-e2e/framework/harness.rs +++ b/tests/backend-e2e/framework/harness.rs @@ -21,9 +21,11 @@ use dash_evo_tool::app_dir::ensure_env_file; use dash_evo_tool::backend_task::BackendTask; use dash_evo_tool::backend_task::core::{CoreTask, PaymentRecipient, WalletPaymentRequest}; use dash_evo_tool::backend_task::error::TaskError; +use dash_evo_tool::backend_task::platform_info::PlatformInfoTaskRequestType; use dash_evo_tool::context::AppContext; use dash_evo_tool::context::connection_status::ConnectionStatus; use dash_evo_tool::database::test_helpers::create_database_at_path; +use dash_evo_tool::model::user_role::{UserRole, UserRoleCell}; use dash_evo_tool::model::wallet::WalletSeedHash; use dash_evo_tool::utils::egui_mpsc::EguiMpscAsync; use dash_evo_tool::utils::tasks::TaskManager; @@ -262,7 +264,7 @@ impl BackendTestContext { egui_ctx, app_kv, secret_store, - dash_evo_tool::model::user_role::UserRoleCell::default(), + UserRoleCell::new(UserRole::Power), ) .expect("Failed to create AppContext for testnet"); @@ -452,6 +454,13 @@ impl BackendTestContext { .expect("SPV did not reach Running state within 600s"); tracing::info!("SPV fully synced — mempool bloom filter active"); + run_task( + &app_context, + BackendTask::PlatformInfo(PlatformInfoTaskRequestType::CurrentEpochInfo), + ) + .await + .expect("Failed to fetch current epoch information"); + // Now check framework wallet balance — SPV has synced, so balances // should be available immediately (no need for a long timeout). tracing::info!("Waiting for SPV to sync framework wallet spendable balance..."); diff --git a/tests/backend-e2e/framework/shielded_helpers.rs b/tests/backend-e2e/framework/shielded_helpers.rs index 268b8f02d..14b9d6067 100644 --- a/tests/backend-e2e/framework/shielded_helpers.rs +++ b/tests/backend-e2e/framework/shielded_helpers.rs @@ -12,13 +12,13 @@ use dash_evo_tool::model::wallet::WalletSeedHash; use std::sync::Arc; /// Check whether the connected platform supports shielded operations -/// via the `FeatureGate::Shielded` protocol version check. +/// through the protocol-version and interface-role checks. /// /// Returns `true` if shielded state transitions are available. Call this /// early in shielded tests to skip proactively instead of waiting for an /// error from the backend task. pub fn is_shielded_available(app_context: &AppContext) -> bool { - FeatureGate::Shielded.is_available(app_context) + FeatureGate::ShieldedOperations.is_available(app_context) } /// Check `E2E_SKIP_SHIELDED` env var and skip the calling test if set.