Skip to content
Merged
Show file tree
Hide file tree
Changes from 31 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
35 changes: 35 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,41 @@ 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.

- **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.
8 changes: 6 additions & 2 deletions docs/user-stories.md
Original file line number Diff line number Diff line change
Expand Up @@ -670,8 +670,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.
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
24 changes: 18 additions & 6 deletions src/ui/components/legacy_recovery_section.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ 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::theme::DashColors;

/// Section lead-in. Avoids "migration", "blob" and "vault" — the user knows
Expand Down Expand Up @@ -95,13 +95,15 @@ 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(
vocabulary,
item.is_on_voter_identity(),
item.purpose.unwrap_or(Purpose::AUTHENTICATION),
);
Expand Down Expand Up @@ -155,14 +157,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 Down Expand Up @@ -196,7 +207,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 +249,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