goblin: remove the rotate-nostr-identity feature
Rotation just does Keys::generate (IdentitySource::Random) — the old seed-derived path was dropped in Build 8 — so it is functionally a random add-identity-and-switch, and it does NOT carry the username or profile over (it RELEASES the old name and hands the new key a nameless identity). It therefore adds nothing over the identity switcher, so remove it: the Settings entry row, the inline rotate flow (RotateState + rotate_ui) and the now-unreachable Wallet::rotate_nostr_identity. Shared helpers (unlock_all_identities, create_random, nip05::unregister) are untouched. Drops the fifteen unused goblin.settings.rotate_* / key_rotated / new_npub / backup_new_key / copy_new_nsec / rotation_failed keys from all eight locales; drift + call-site tests green.
This commit is contained in:
@@ -75,8 +75,6 @@ pub struct GoblinWalletView {
|
||||
wallet_id: Option<String>,
|
||||
/// Inline username-claim state for the Me tab.
|
||||
claim: Option<ClaimState>,
|
||||
/// Inline key-rotation state for the Me tab.
|
||||
rotate: Option<RotateState>,
|
||||
/// Inline nsec-import state for the Me tab.
|
||||
import_nsec: Option<ImportState>,
|
||||
/// Inline "back up identity to a file" flow state.
|
||||
@@ -300,7 +298,6 @@ impl Default for GoblinWalletView {
|
||||
request_error: None,
|
||||
wallet_id: None,
|
||||
claim: None,
|
||||
rotate: None,
|
||||
import_nsec: None,
|
||||
backup: None,
|
||||
identity_switch: IdentitySwitchState::default(),
|
||||
@@ -340,31 +337,6 @@ impl Default for GoblinWalletView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Inline key-rotation flow state (two warnings + typed confirmation).
|
||||
struct RotateState {
|
||||
/// 1 = warning, 2 = confirm (RESET + password), 3 = working,
|
||||
/// 4 = done (new npub), 5 = error (message).
|
||||
stage: u8,
|
||||
reset_input: String,
|
||||
password: String,
|
||||
new_npub: String,
|
||||
error: String,
|
||||
result: std::sync::Arc<std::sync::Mutex<Option<Result<String, String>>>>,
|
||||
}
|
||||
|
||||
impl Default for RotateState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
stage: 1,
|
||||
reset_input: String::new(),
|
||||
password: String::new(),
|
||||
new_npub: String::new(),
|
||||
error: String::new(),
|
||||
result: std::sync::Arc::new(std::sync::Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Inline nsec-import flow state (restore path for the random-key model).
|
||||
struct ImportState {
|
||||
/// 1 = form, 3 = working, 4 = done, 5 = error.
|
||||
@@ -816,7 +788,6 @@ impl GoblinWalletView {
|
||||
self.receipt = None;
|
||||
self.profile = None;
|
||||
self.claim = None;
|
||||
self.rotate = None;
|
||||
self.import_nsec = None;
|
||||
self.approve_review = None;
|
||||
self.approve_hold = w::HoldToSend::default();
|
||||
@@ -3666,14 +3637,6 @@ impl GoblinWalletView {
|
||||
{
|
||||
self.backup = Some(BackupState::default());
|
||||
}
|
||||
if settings_row_danger(
|
||||
ui,
|
||||
&t!("goblin.settings.rotate_key"),
|
||||
crate::gui::icons::ARROWS_CLOCKWISE,
|
||||
) && self.rotate.is_none()
|
||||
{
|
||||
self.rotate = Some(RotateState::default());
|
||||
}
|
||||
if settings_row_btn(
|
||||
ui,
|
||||
&t!("goblin.settings.import_identity"),
|
||||
@@ -3754,10 +3717,6 @@ impl GoblinWalletView {
|
||||
ui.add_space(8.0);
|
||||
self.backup_ui(ui, wallet, cb);
|
||||
}
|
||||
if self.rotate.is_some() {
|
||||
ui.add_space(8.0);
|
||||
self.rotate_ui(ui, wallet, cb);
|
||||
}
|
||||
if self.import_nsec.is_some() {
|
||||
ui.add_space(8.0);
|
||||
self.import_nsec_ui(ui, wallet, cb);
|
||||
@@ -5357,234 +5316,6 @@ impl GoblinWalletView {
|
||||
});
|
||||
}
|
||||
|
||||
/// Inline key-rotation flow: warning → typed RESET + password → result.
|
||||
fn rotate_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) {
|
||||
let t = theme::tokens();
|
||||
let rotate = self.rotate.as_mut().unwrap();
|
||||
// Poll the worker result.
|
||||
if rotate.stage == 3 {
|
||||
if let Some(res) = rotate.result.lock().unwrap().take() {
|
||||
match res {
|
||||
Ok(npub) => {
|
||||
rotate.new_npub = npub;
|
||||
rotate.stage = 4;
|
||||
}
|
||||
Err(e) => {
|
||||
rotate.error = e;
|
||||
rotate.stage = 5;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut close = false;
|
||||
w::card(ui, |ui| {
|
||||
ui.set_min_width(ui.available_width());
|
||||
match rotate.stage {
|
||||
1 => {
|
||||
ui.label(
|
||||
RichText::new(t!("goblin.settings.rotate_key"))
|
||||
.font(FontId::new(15.0, fonts::semibold()))
|
||||
.color(t.neg),
|
||||
);
|
||||
ui.add_space(6.0);
|
||||
for line in [
|
||||
t!("goblin.settings.rotate_line1"),
|
||||
t!("goblin.settings.rotate_line2"),
|
||||
t!("goblin.settings.rotate_line3"),
|
||||
t!("goblin.settings.rotate_line4"),
|
||||
t!("goblin.settings.rotate_line5"),
|
||||
] {
|
||||
ui.label(
|
||||
RichText::new(line)
|
||||
.font(FontId::new(13.0, fonts::regular()))
|
||||
.color(t.surface_text_dim),
|
||||
);
|
||||
ui.add_space(4.0);
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
let half = (ui.available_width() - 10.0) / 2.0;
|
||||
ui.scope_builder(
|
||||
egui::UiBuilder::new().max_rect(egui::Rect::from_min_size(
|
||||
ui.cursor().min,
|
||||
Vec2::new(half, 44.0),
|
||||
)),
|
||||
|ui| {
|
||||
if w::big_action_on_card(ui, &t!("goblin.settings.cancel"))
|
||||
.clicked()
|
||||
{
|
||||
close = true;
|
||||
}
|
||||
},
|
||||
);
|
||||
ui.add_space(10.0);
|
||||
ui.scope_builder(
|
||||
egui::UiBuilder::new().max_rect(egui::Rect::from_min_size(
|
||||
ui.cursor().min,
|
||||
Vec2::new(half, 44.0),
|
||||
)),
|
||||
|ui| {
|
||||
if w::big_action(ui, &t!("goblin.settings.continue"), false)
|
||||
.clicked()
|
||||
{
|
||||
rotate.stage = 2;
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
2 => {
|
||||
ui.label(
|
||||
RichText::new(t!("goblin.settings.final_confirmation"))
|
||||
.font(FontId::new(15.0, fonts::semibold()))
|
||||
.color(t.neg),
|
||||
);
|
||||
ui.add_space(6.0);
|
||||
ui.label(
|
||||
RichText::new(t!("goblin.settings.rotate_confirm_blurb"))
|
||||
.font(FontId::new(13.0, fonts::regular()))
|
||||
.color(t.surface_text_dim),
|
||||
);
|
||||
ui.add_space(10.0);
|
||||
w::field_well(ui, |ui| {
|
||||
TextEdit::new(egui::Id::from("rotate_reset"))
|
||||
.focus(false)
|
||||
.hint_text(t!("goblin.settings.type_reset"))
|
||||
.text_color(t.surface_text)
|
||||
.body()
|
||||
.ui(ui, &mut rotate.reset_input, cb);
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
w::field_well(ui, |ui| {
|
||||
TextEdit::new(egui::Id::from("rotate_pass"))
|
||||
.focus(false)
|
||||
.hint_text(t!("goblin.settings.wallet_password"))
|
||||
.password()
|
||||
.text_color(t.surface_text)
|
||||
.body()
|
||||
.ui(ui, &mut rotate.password, cb);
|
||||
});
|
||||
ui.add_space(10.0);
|
||||
let armed = rotate.reset_input.trim() == "RESET" && !rotate.password.is_empty();
|
||||
ui.horizontal(|ui| {
|
||||
let half = (ui.available_width() - 10.0) / 2.0;
|
||||
ui.scope_builder(
|
||||
egui::UiBuilder::new().max_rect(egui::Rect::from_min_size(
|
||||
ui.cursor().min,
|
||||
Vec2::new(half, 44.0),
|
||||
)),
|
||||
|ui| {
|
||||
if w::big_action_on_card(ui, &t!("goblin.settings.cancel"))
|
||||
.clicked()
|
||||
{
|
||||
close = true;
|
||||
}
|
||||
},
|
||||
);
|
||||
ui.add_space(10.0);
|
||||
ui.scope_builder(
|
||||
egui::UiBuilder::new().max_rect(egui::Rect::from_min_size(
|
||||
ui.cursor().min,
|
||||
Vec2::new(half, 44.0),
|
||||
)),
|
||||
|ui| {
|
||||
ui.add_enabled_ui(armed, |ui| {
|
||||
if w::big_action(
|
||||
ui,
|
||||
&t!("goblin.settings.rotate_key_btn"),
|
||||
false,
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
rotate.stage = 3;
|
||||
let slot = rotate.result.clone();
|
||||
let password = std::mem::take(&mut rotate.password);
|
||||
rotate.reset_input.clear();
|
||||
let wallet = wallet.clone();
|
||||
std::thread::spawn(move || {
|
||||
let res = wallet.rotate_nostr_identity(password);
|
||||
*slot.lock().unwrap() = Some(res);
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
3 => {
|
||||
ui.horizontal(|ui| {
|
||||
View::small_loading_spinner(ui);
|
||||
ui.add_space(8.0);
|
||||
ui.label(
|
||||
RichText::new(t!("goblin.settings.rotating_key"))
|
||||
.font(FontId::new(13.0, fonts::regular()))
|
||||
.color(t.surface_text_dim),
|
||||
);
|
||||
});
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
4 => {
|
||||
ui.label(
|
||||
RichText::new(t!("goblin.settings.key_rotated"))
|
||||
.font(FontId::new(15.0, fonts::semibold()))
|
||||
.color(t.pos),
|
||||
);
|
||||
ui.add_space(4.0);
|
||||
let npub = &rotate.new_npub;
|
||||
let short = if npub.len() > 18 {
|
||||
format!("{}…{}", &npub[..12], &npub[npub.len() - 6..])
|
||||
} else {
|
||||
npub.clone()
|
||||
};
|
||||
ui.label(
|
||||
RichText::new(t!("goblin.settings.new_npub", npub => short))
|
||||
.font(FontId::new(13.0, fonts::mono()))
|
||||
.color(t.surface_text_dim),
|
||||
);
|
||||
ui.add_space(6.0);
|
||||
ui.label(
|
||||
RichText::new(t!("goblin.settings.backup_new_key"))
|
||||
.font(FontId::new(13.0, fonts::semibold()))
|
||||
.color(t.neg),
|
||||
);
|
||||
ui.add_space(10.0);
|
||||
if w::big_action_on_card(ui, &t!("goblin.settings.copy_new_nsec")).clicked() {
|
||||
if let Some(nsec) = wallet.nostr_service().and_then(|s| s.nsec()) {
|
||||
// Secret: auto-clears from the clipboard after a delay
|
||||
// (compare-then-clear) so it does not linger there.
|
||||
cb.copy_secret_to_buffer(nsec);
|
||||
cb.vibrate_copy();
|
||||
}
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
if w::big_action(ui, &t!("goblin.settings.done"), false).clicked() {
|
||||
close = true;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
ui.label(
|
||||
RichText::new(t!("goblin.settings.rotation_failed"))
|
||||
.font(FontId::new(15.0, fonts::semibold()))
|
||||
.color(t.neg),
|
||||
);
|
||||
ui.add_space(4.0);
|
||||
ui.label(
|
||||
RichText::new(&rotate.error)
|
||||
.font(FontId::new(13.0, fonts::regular()))
|
||||
.color(t.surface_text_dim),
|
||||
);
|
||||
ui.add_space(10.0);
|
||||
if w::big_action_on_card(ui, &t!("goblin.settings.close")).clicked() {
|
||||
close = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
if close {
|
||||
self.rotate = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Inline "back up identity to a file" flow: ask for the wallet password,
|
||||
/// seal the identity, and write a GOBLIN-*.backup file via the native picker.
|
||||
fn backup_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) {
|
||||
|
||||
+3
-71
@@ -604,74 +604,6 @@ impl Wallet {
|
||||
.map_err(|e| format!("nsec encode failed: {e}"))
|
||||
}
|
||||
|
||||
pub fn rotate_nostr_identity(&self, password: String) -> Result<String, String> {
|
||||
let svc = self
|
||||
.nostr_service()
|
||||
.ok_or_else(|| "nostr is not running".to_string())?;
|
||||
// Snapshot the old identity and prove the password by unlocking it
|
||||
// (NIP-49 decryption fails on a wrong password).
|
||||
let old = svc.identity.read().clone();
|
||||
let old_keys = old
|
||||
.unlock(&password)
|
||||
.map_err(|_| "Wrong password".to_string())?;
|
||||
|
||||
// Generate the replacement identity.
|
||||
let (mut new_identity, _new_keys) = NostrIdentity::create_random(&password)
|
||||
.map_err(|e| format!("key generation failed: {e}"))?;
|
||||
|
||||
// Release the username first (the server also deletes its avatar);
|
||||
// abort the rotation if that fails so the user never ends up with a
|
||||
// burned key still welded to a public name. After rotation the name
|
||||
// is up for grabs — by the new key or anyone else.
|
||||
if let Some(nip05) = old.nip05.clone() {
|
||||
let name = nip05.split('@').next().unwrap_or_default().to_string();
|
||||
let server = { svc.config.read().nip05_server() };
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
rt.block_on(async { crate::nostr::nip05::unregister(&server, &name, &old_keys).await })
|
||||
.map_err(|e| format!("Couldn't release @{name}: {e} — rotation cancelled"))?;
|
||||
}
|
||||
new_identity.prev_npubs = {
|
||||
let mut v = old.prev_npubs.clone();
|
||||
v.push(old.npub.clone());
|
||||
v
|
||||
};
|
||||
|
||||
// Persist, then swap the running service for one bound to the new key.
|
||||
let config = self.get_config();
|
||||
let nostr_dir = config.get_nostr_path();
|
||||
new_identity
|
||||
.save(&nostr_dir)
|
||||
.map_err(|e| format!("identity save failed: {e}"))?;
|
||||
svc.stop();
|
||||
for _ in 0..100 {
|
||||
if !svc.is_running() {
|
||||
break;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
let wallet_dir = PathBuf::from(config.get_data_path());
|
||||
let nostr_config = NostrConfig::load(wallet_dir);
|
||||
let store = NostrStore::new(config.get_nostr_db_path());
|
||||
let new_npub = new_identity.npub.clone();
|
||||
// Rebuild the service holding ALL held identities (the primary entry now
|
||||
// resolves to the new identity), with the new one active.
|
||||
let (recv, _) = self
|
||||
.unlock_all_identities(&nostr_dir, &password)
|
||||
.ok_or_else(|| "identity unlock failed".to_string())?;
|
||||
let active_hex = new_identity.pubkey_hex().unwrap_or_default();
|
||||
let new_svc = NostrService::new(recv, &active_hex, nostr_config, store, nostr_dir);
|
||||
{
|
||||
let mut w_nostr = self.nostr.write();
|
||||
*w_nostr = Some(new_svc.clone());
|
||||
}
|
||||
new_svc.start(self.clone());
|
||||
info!("nostr: identity rotated to {}", new_npub);
|
||||
Ok(new_npub)
|
||||
}
|
||||
|
||||
/// Replace the nostr identity with an imported key — either a bare nsec
|
||||
/// or an exported identity-backup JSON (which restores the username and
|
||||
/// rotation history too). The current identity is overwritten; its npub
|
||||
@@ -791,9 +723,9 @@ impl Wallet {
|
||||
// identities and present a different one at will. Exactly one is ACTIVE: it
|
||||
// drives the single live gift-wrap subscription and all display, and every
|
||||
// identity redeems into the SAME shared grin balance. Only the active nsec is
|
||||
// ever decrypted into memory; the rest rest as ncryptsec on disk. Switching is
|
||||
// mechanically identical to `rotate_nostr_identity` (stop the service, rebuild
|
||||
// it on the target key, restart), plus a per-identity catch-up so payments that
|
||||
// ever decrypted into memory; the rest rest as ncryptsec on disk. Switching
|
||||
// stops the service and rebuilds it on the target key, then restarts, plus a
|
||||
// per-identity catch-up so payments that
|
||||
// arrived while an identity was dormant are redeemed on switch-in.
|
||||
|
||||
/// The held identities for the identity switcher (no secrets). Empty if nostr
|
||||
|
||||
Reference in New Issue
Block a user