Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
efee42d
feat(model): add the legacy identity recovery plan and additive merge
lklimek Jul 28, 2026
3bab9a9
feat(database): read one legacy identity row through the shared row d…
lklimek Jul 28, 2026
b677776
feat(identity): restore keys stranded in the previous version's saved…
lklimek Jul 28, 2026
c2e859f
feat(masternodes): offer the previous version's stranded keys on the …
lklimek Jul 28, 2026
c274558
feat(identities): offer stranded legacy keys on the Key Info screen
lklimek Jul 28, 2026
e484589
docs(identity): record the legacy-key recovery flow and close the mig…
lklimek Jul 28, 2026
63991b7
fix(model): only offer legacy keys that still correspond to the identity
lklimek Jul 28, 2026
f405098
fix(identity): stop holding the storage guard across the password prompt
lklimek Jul 28, 2026
7caf529
fix(ui): keep the restore offer and the record it wrote in step
lklimek Jul 28, 2026
fc125f4
docs(identity): record what the recovery flow actually shipped
lklimek Jul 28, 2026
24d3081
fix(model): stop the legacy blob from vouching for its own voter key
lklimek Jul 29, 2026
4c42e55
fix(identity): gate the recovery offer on legacy rows, not on data.db…
lklimek Jul 29, 2026
cdde7f1
fix(ui): re-read the key screen on arrival and attribute a finished r…
lklimek Jul 29, 2026
be5299b
test(identity): pin the mid-flight protection guard against regression
lklimek Jul 29, 2026
ad22a2c
fix(model): stop the legacy blob from vouching for its own operator key
lklimek Jul 29, 2026
bff08f7
fix(masternodes): drop the missing-voter message that can never be shown
lklimek Jul 29, 2026
171c512
fix(ui): let the recovery section read as a whole when it can restore…
lklimek Jul 29, 2026
1612fc7
docs(identity): stop promising a voting-key restore that cannot happen
lklimek Jul 29, 2026
2ade1b8
fix(identity): give the identity keys list a way into each key
lklimek Jul 29, 2026
8db5341
test(identity): pin the restore outcome and the key naming on the key…
lklimek Jul 29, 2026
b1e2f28
test(identity): report both restore outcomes on the keys list in the …
lklimek Jul 29, 2026
ef665b1
fix(identity): make the keys list and Key Info agree on where a key i…
lklimek Jul 30, 2026
a237497
fix(identity): return from a key to a current list, in the identity's…
lklimek Jul 30, 2026
cb5b0de
fix(identity): one attribution rule for both hosts of the recovery offer
lklimek Jul 30, 2026
f36b4ca
fix(identity): identify a key by its public half, and name it the sam…
lklimek Jul 30, 2026
2dbc996
fix(identity): Key Info names a user's keys the way the keys list does
lklimek Jul 30, 2026
d39457b
fix(identity): a disabled key the device holds is still reported as held
lklimek Jul 30, 2026
91fbc73
fix(masternodes): a key opened from a node's page keeps the name the …
lklimek Jul 30, 2026
4042c01
refactor(identity): one held-key resolution rule for every Manage key…
lklimek Jul 30, 2026
a028d7e
refactor(identity): make an upstream key field a build failure, not a…
lklimek Jul 30, 2026
1a944eb
Merge remote-tracking branch 'origin/v1.0-dev' into fix/889-key-info-…
lklimek Jul 30, 2026
f334875
fix(identity): reach Key Info's re-read, share the restore offer, nam…
lklimek Jul 30, 2026
2c264bb
docs(user-stories): point IDN-008 at the key list that shipped
lklimek Jul 30, 2026
af84ea6
docs(changelog): document the Key Info refresh and label-consistency …
lklimek Jul 30, 2026
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
48 changes: 48 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,54 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

### Fixed

- **An identity's keys are reachable again**: the keys list under an identity's
Settings → Advanced now opens each key's own page, so keys can be inspected
and restored — and, once a key is on this device, signed with or
password-protected — without changing the interface mode and without starting
a payment. Previously that list was a read-only table with no way onward, and
every route to a key's page ran through an action screen — sending,
withdrawing, a token operation — each of which offers it only when the
identity already holds a key of the kind that action needs. So an identity
missing its keys, the one case where this matters most, could not get to them
at all. The offer to restore keys left behind by an earlier version now also
appears on the keys list itself, above the keys, rather than only inside a
key's page. Each key is named by its role and states whether it is saved on
this device. Keys are named for the identity they belong to: a user identity's
keys are described in plain language rather than in masternode registration
terms, which previously appeared on every identity. Leaving a key returns to
the list with both its keys and the restore offer brought up to date, so a
restore made from a key's page is reflected immediately instead of being
offered again.

A key opened from a masternode's page keeps its name too. A voting key is the
node's voting key however it is recorded, and its own page now says so instead
of describing it as another kind of key, which also means the page no longer
reports such a key as missing while the list it was opened from shows it as
saved on this device.

One known limitation, for a voting key stored on the identity itself rather
than on a separate voting identity: the keys list and the key's page now agree
on whether such a key is saved here, but saving or removing one by hand can
affect a voting key of the same number on a linked voting identity, and
removing it may leave the original in place. So until then, after saving or
removing a voting key on an identity like that, open the keys list and check
that each key still reads as you expect, and re-enter any key that should be
saved but no longer is. This will be closed by the in-progress key-placement
resolution fix.

- **A key's page now catches up on changes made while it was open**: previously,
if something else updated your identity while a key's page was open — most
relevantly, a restore that finished from a different screen — the next key
edit made on that page could silently overwrite the change. The page now
picks up such changes as they arrive.

- **Key role names are complete, consistent phrases everywhere**: a key's role
(owner, voting, payout, and so on) now reads the same complete phrase across
the keys list, a masternode's page, and the key's own page, instead of a
partly-assembled label that could vary by screen. The on-chain purpose value
itself remains available as its own line in Expert view for anyone who wants
it verbatim.

- **Wallet rename consistency**: renaming a wallet no longer overwrites other
saved wallet details when metadata cannot be read. Overlapping renames and
wallet removals also keep displayed aliases and deleted-wallet metadata
Expand Down
30 changes: 30 additions & 0 deletions docs/ai-design/2026-07-28-legacy-identity-recovery/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -792,3 +792,33 @@ the eager vault migration inside the read path — a caller already holding the
guard reaches it, so taking it there would self-deadlock. That write is
idempotent (it replaces plaintext with vault placeholders in the blob it just
read) and carries a TODO to fold it into the guarded path.

### 10.14 The Key Info offer needed a route before it was reachable at all

§2.1 named the Key Info screen as reachable "from the Identities screen for
`User` identities and via 'Manage keys' for nodes", and §7 row 1 called the
entry point discoverable. Both were false for `User` identities as shipped.

The legacy `IdentitiesScreen` per-key popup is the route §2.1 means, and
`ui/components/left_panel.rs` deliberately drops `RootScreenIdentities` from the
nav, so nothing navigates to it. The identity hub's replacement surface —
Settings → Advanced → "Manage keys" — opened a read-only key table with no way
into `KeyInfoScreen`. Every remaining route (transfer, withdraw, the token
screens) is gated on the identity already holding a key of the kind that action
needs, which is exactly false for the identities this flow exists to help. A
`User` identity with stranded keys could therefore reach the offer only in
Developer view, through a send-money screen.

§2.1 and §7 row 1 are both left as written, per this document's model — the
proposal stands as proposed and this section is the correction. A third surface
now carries the offer that §2.1 names two for, and the mechanism §7 row 1 calls
an "on-arrival" detection task is the `ensure_checked()` render-loop latch of
§10.1.

Shipped: `KeysScreen` carries the `QualifiedIdentity`, renders one row per key
in the §10.8 vocabulary with its held state in words, and opens
`KeyInfoScreen` for any key regardless of what the device holds. The offer
itself renders on that list, above the rows, because it is identity-scoped —
requiring a user to pick an arbitrary key to discover an identity-level offer
repeats the defect one level up. The offer stays on `KeyInfoScreen` too: it
self-extinguishes, and the masternode path lands there.
18 changes: 13 additions & 5 deletions docs/user-stories.md
Original file line number Diff line number Diff line change
Expand Up @@ -570,12 +570,16 @@ As a power user, I want to add a new key to my identity so that I can authorize
- Key is added via state transition.

### IDN-008: View identity keys and details [Implemented]
**Persona:** Priya, Jordan
**Persona:** Alex, Priya, Jordan

As a user, I want to view all keys associated with my identity so that I can audit access and verify key configuration.

- Lists all keys with type, purpose, and status.
- View individual key details.
- The list is reachable from the identity's own Settings tab, under the Advanced section, without changing the interface mode and without starting a payment.
- Every key on the identity gets a row, and every row opens that key's own page — whether or not this device holds the key's private half. A key the device is missing is exactly the one a user comes here about, so it is never hidden or unopenable.
- Each row names the key by its role in words that suit the identity — a user identity's keys in plain language, a masternode's in its registration terms — and says whether the key is saved on this device, in words rather than by colour alone. A key the network has retired says so in its name.
- The on-chain specifics — key id, Platform purpose, security level, key type, read-only — are Expert-view detail, on both the list and the key's own page. The Everyday view gets the role and the held state, which is what it can act on.
- A key's own page shows its public key, hash and address, and offers to view the private half, sign a message with it, or add and remove it.
- The offer to restore keys an upgrade left behind also appears here; its criteria are IDN-020's.

### IDN-013: Password-protect an identity's signing keys (SEC-001) [Implemented]
**Persona:** Priya, Jordan
Expand Down Expand Up @@ -670,8 +674,12 @@ As a user, I want the identities I loaded before an upgrade — and the keys the

As a user whose identity was already in the app before the upgrade — a masternode loaded from its ProTxHash, or an identity holding only some of its keys — I want to bring across the keys that stayed behind in the previous version's data, so that I do not have to re-enter private keys I no longer have on hand.

- The offer appears on the identity's own page — the node detail page and the Key Info screen — only when the previous version's data actually holds keys this identity does not, and it disappears once there is nothing left to restore.
- The list names each key by its role and is exactly what gets restored. Nothing is restored without pressing Restore, and nothing happens automatically at launch or during the upgrade.
- The offer appears on the identity's own page — the node detail page, the identity's keys list, and the Key Info screen — only when the previous version's data actually holds keys this identity does not, and it disappears once there is nothing left to restore.
- The keys list is reachable without changing the interface mode and without starting a payment: an identity's keys are listed under Settings, and every key opens its own page whether or not this device holds that key. Keys that are missing are exactly the ones a user comes here about, so a key the device does not hold is never hidden or unopenable.
Comment thread
lklimek marked this conversation as resolved.
- The offer sits above the key list, so a user whose keys are missing finds it without opening a key first.
- Each key is named by its role and says whether it is saved on this device, in words rather than by colour alone. The role words match the identity: a user identity's keys are named in plain language, not in masternode registration terms, and the restore offer above the list uses the same words as the list itself.
- Leaving a key returns to the keys list with both the keys and the restore offer up to date, so a restore made from a key's page is never offered a second time.
- The restore offer lists each key by its role, and that list is exactly what gets restored. Nothing is restored without pressing Restore, and nothing happens automatically at launch or during the upgrade.
- Keys already saved for the identity are never replaced or removed — only missing ones are added. An identity the user deleted is never brought back.
- On a password-protected identity the identity password is asked for first; cancelling, or getting it wrong, leaves everything exactly as it was.
- An owner or payout key that is genuinely missing comes back this way. A voting key held on a separate voting identity does not, unless the identity's own record already links to that voting identity: otherwise nothing but the previous version's data says the node still uses that key, so it is listed as one that cannot be brought back and entering it by hand stays the remedy. Checking such a key against the chain, which would let it be restored safely, is tracked as issue #942.
Expand Down
65 changes: 57 additions & 8 deletions src/ui/components/legacy_recovery_section.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ use crate::model::legacy_recovery::{
ExclusionReason, RecoveryItem, RecoveryItemDescriptor, RecoveryPlan,
};
use crate::ui::components::component_trait::{Component, ComponentResponse};
use crate::ui::masternodes::{disambiguate_role_labels, role_label_and_tip};
use crate::ui::masternodes::{KeyVocabulary, disambiguate_role_labels, role_label_and_tip};
use crate::ui::state::legacy_recovery::LegacyRecoveryState;
use crate::ui::theme::DashColors;

/// Section lead-in. Avoids "migration", "blob" and "vault" — the user knows
Expand Down Expand Up @@ -95,17 +96,22 @@ pub fn exclusion_explanation(reason: ExclusionReason) -> &'static str {
/// item is named here exactly as the "Manage keys" list names the same key, and
/// two rows that would otherwise read alike are told apart by their key id.
pub fn recovery_item_labels(
vocabulary: KeyVocabulary,
items: &[&RecoveryItemDescriptor],
) -> Vec<(String, Option<&'static str>)> {
let mut labels: Vec<(String, Option<&'static str>)> = items
.iter()
.map(|item| match &item.item {
RecoveryItem::Key { .. } => {
let (role, tip) = role_label_and_tip(
let (label, tip) = role_label_and_tip(
vocabulary,
item.is_on_voter_identity(),
item.purpose.unwrap_or(Purpose::AUTHENTICATION),
// The previous version's data records no retired state, and
// a key still restorable is one the identity still uses.
false,
);
(format!("{role} key"), tip)
(label.to_string(), tip)
}
RecoveryItem::VoterAssociation => ("Voting identity link".to_string(), None),
RecoveryItem::OperatorAssociation => ("Operator identity link".to_string(), None),
Expand Down Expand Up @@ -155,14 +161,23 @@ impl ComponentResponse for LegacyRecoverySectionResponse {
pub struct LegacyRecoverySection<'a> {
plan: &'a RecoveryPlan,
restoring: bool,
vocabulary: KeyVocabulary,
}

impl<'a> LegacyRecoverySection<'a> {
/// The offer for `plan`.
pub fn new(plan: &'a RecoveryPlan) -> Self {
/// The offer for `plan`, naming its keys in `vocabulary`.
///
/// `vocabulary` is an argument rather than a builder option on purpose. This
/// offer is hosted by three separate screens, and a default would let a
/// fourth inherit the wrong wording silently: naming a user identity's
/// transfer key a "payout address key" asserts it owns a masternode, and
/// disagrees with the keys list rendered right below the offer. Required
/// here, every host has to answer for the identity it is showing.
pub fn new(plan: &'a RecoveryPlan, vocabulary: KeyVocabulary) -> Self {
Self {
plan,
restoring: false,
vocabulary,
}
}

Expand All @@ -174,6 +189,39 @@ impl<'a> LegacyRecoverySection<'a> {
}
}

/// Render `state`'s offer for a hosting screen, returning the items the user
/// approved this frame. Renders nothing at all when detection found nothing, so
/// the offer appears only where it has something to say and retires itself once
/// a restore lands.
///
/// The whole of what a host owes the offer's *rendering*, so no host has to
/// restate it. Three screens host this one offer, and each restatement is a
/// place the next one can drift: naming a key differently, forgetting the
/// in-flight guard that stops a restore being dispatched twice, or showing an
/// empty section. A host supplies only the identity's `vocabulary` and whatever
/// separators frame it — see [`LegacyRecoveryState::has_offer`] for deciding
/// whether to draw those.
///
/// `vocabulary` stays an argument for the reason [`LegacyRecoverySection::new`]
/// gives: naming a user identity's transfer key a "payout address key" asserts
/// it owns a masternode.
pub fn host_offer(
state: &LegacyRecoveryState,
vocabulary: KeyVocabulary,
ui: &mut Ui,
) -> Option<Vec<RecoveryItem>> {
let restoring = state.is_restoring();
let plan = state.plan().filter(|plan| !plan.is_empty())?;
// `changed_value` hands back a reference into the response, so the approved
// set has to be cloned out before the response is dropped.
LegacyRecoverySection::new(plan, vocabulary)
.restoring(restoring)
.show(ui)
.inner
.changed_value()
.clone()
}

impl Component for LegacyRecoverySection<'_> {
type DomainType = Vec<RecoveryItem>;
type Response = LegacyRecoverySectionResponse;
Expand All @@ -196,7 +244,7 @@ impl Component for LegacyRecoverySection<'_> {
ui.label(RichText::new(intro).color(DashColors::warning_color(dark_mode)));

let previewed = self.plan.preview_items();
for (label, tip) in recovery_item_labels(&previewed) {
for (label, tip) in recovery_item_labels(self.vocabulary, &previewed) {
ui.horizontal(|ui| {
ui.add_space(12.0);
let row = ui
Expand Down Expand Up @@ -238,8 +286,9 @@ impl Component for LegacyRecoverySection<'_> {
let excluded: Vec<&RecoveryItemDescriptor> =
self.plan.excluded.iter().map(|(item, _)| item).collect();
let reasons = self.plan.excluded.iter().map(|(_, reason)| *reason);
for ((label, _), reason) in
recovery_item_labels(&excluded).into_iter().zip(reasons)
for ((label, _), reason) in recovery_item_labels(self.vocabulary, &excluded)
.into_iter()
.zip(reasons)
{
ui.horizontal(|ui| {
ui.add_space(12.0);
Expand Down
Loading
Loading