Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,19 @@ 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,
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
Expand Down
108 changes: 78 additions & 30 deletions src/context/feature_gate.rs
Original file line number Diff line number Diff line change
@@ -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<u32> = 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<u32> =
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
Expand All @@ -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
Expand Down Expand Up @@ -164,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<Check> {
self.checks().iter().copied().find(|c| !c.is_met(ctx))
}
}

#[cfg(test)]
Expand Down Expand Up @@ -303,32 +307,76 @@ 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));
}
Comment thread
lklimek marked this conversation as resolved.

#[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));
}

#[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
Expand Down
44 changes: 33 additions & 11 deletions src/ui/wallets/shielded_tab.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -710,8 +716,12 @@ impl ShieldedTabView {
});
}
} else {
let label = match FeatureGate::ShieldedOperations.first_unmet_check(&self.app_context) {
Comment thread
claude[bot] marked this conversation as resolved.
Outdated
Some(Check::Experimental(_)) => SHIELDED_OPERATIONS_ROLE_UNAVAILABLE_LABEL,
_ => SHIELDED_OPERATIONS_NETWORK_UNAVAILABLE_LABEL,
Comment thread
claude[bot] marked this conversation as resolved.
Outdated
};
Comment thread
claude[bot] marked this conversation as resolved.
Outdated
ui.label(
RichText::new(SHIELDED_OPERATIONS_UNAVAILABLE_LABEL)
RichText::new(label)
.size(12.0)
.color(DashColors::text_secondary(dark_mode)),
);
Expand Down Expand Up @@ -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
Expand Down
Loading