From 313a14b82cfa95960b8e8420948df85245f521e6 Mon Sep 17 00:00:00 2001 From: 2ro <17595647+2ro@users.noreply.github.com> Date: Tue, 16 Jun 2026 00:31:50 -0400 Subject: [PATCH] wallet: contacts on send, request-approve states, .backup create/import - Create/refresh a contact when you SEND (not just receive) so people you pay show up under Suggested and resolve their @name. - Approve-request: set SENT on the Standard1 success path and fail_send on the error + non-payable paths, so the button never sticks greyed. - create_nostr_backup() + import of the encrypted .backup envelope. --- src/wallet/wallet.rs | 89 ++++++++++++++++++++++++++++++++++++-------- 1 file changed, 73 insertions(+), 16 deletions(-) diff --git a/src/wallet/wallet.rs b/src/wallet/wallet.rs index 3cd739a5..3b4e9734 100644 --- a/src/wallet/wallet.rs +++ b/src/wallet/wallet.rs @@ -558,16 +558,37 @@ impl Wallet { old.unlock(&password) .map_err(|_| "Wrong password".to_string())?; let input = input.trim(); - let (mut new_identity, new_keys) = if input.starts_with('{') { - // Identity backup JSON: decrypt with the password it was exported - // under (may differ on a new device), then RE-ENCRYPT under this - // wallet's password so future unlocks use the current one. + let bpw = backup_password + .as_deref() + .filter(|s| !s.is_empty()) + .unwrap_or(&password); + let (mut new_identity, new_keys) = if NostrIdentity::is_encrypted_backup(input) { + // A GOBLIN-*.backup file: fully sealed. Open it with the password it + // was created under (may differ on a new device), then RE-ENCRYPT + // under this wallet's password so future unlocks use the current one. + let (backup, keys) = + NostrIdentity::from_encrypted_backup(input, bpw).map_err(|_| { + "Couldn't open the backup — wrong password? If it was made on \ + another device, enter that wallet's password in the \ + backup-password field." + .to_string() + })?; + let mut ident = NostrIdentity::from_unlocked_keys( + &keys, + &password, + backup.source, + backup.derivation_account, + ) + .map_err(|e| format!("re-encryption failed: {e}"))?; + ident.nip05 = backup.nip05.clone(); + ident.anonymous = backup.anonymous; + ident.prev_npubs = backup.prev_npubs.clone(); + (ident, keys) + } else if input.starts_with('{') { + // Legacy plaintext identity-backup JSON (pre-.backup-file): decrypt + // with its password, then re-encrypt under this wallet's password. let backup: NostrIdentity = serde_json::from_str(input).map_err(|_| "Invalid identity backup".to_string())?; - let bpw = backup_password - .as_deref() - .filter(|s| !s.is_empty()) - .unwrap_or(&password); let keys = backup.unlock(bpw).map_err(|_| { "Couldn't decrypt the backup — if it was exported on another \ device, enter that wallet's password in the backup-password \ @@ -618,6 +639,21 @@ impl Wallet { Ok(new_npub) } + /// Build the contents of a `GOBLIN-*.backup` file: the whole nostr identity, + /// fully sealed under the wallet password. Verifies the password first. + pub fn create_nostr_backup(&self, password: &str) -> Result { + let svc = self + .nostr_service() + .ok_or_else(|| "nostr is not running".to_string())?; + let identity = svc.identity.read().clone(); + let keys = identity + .unlock(password) + .map_err(|_| "Wrong password".to_string())?; + identity + .to_encrypted_backup(&keys) + .map_err(|e| format!("backup failed: {e}")) + } + /// Get keychain mask [`SecretKey`]. pub fn keychain_mask(&self) -> Option { let r_key = self.keychain_mask.read(); @@ -2314,14 +2350,32 @@ async fn handle_task(w: &Wallet, t: WalletTask) { meta.updated_at = crate::nostr::unix_time(); service.store.save_tx_meta(&meta); } - // Update contact last paid time. - if let Some(mut contact) = service.store.contact(receiver) { - contact.last_paid_at = Some(crate::nostr::unix_time()); - contact.unknown = false; - service.store.save_contact(&contact); - } - // Resolve the recipient's @username so activity shows - // their name, not a bare npub. + // Record/refresh the contact so someone you PAY shows up + // under Suggested (sends used to create no contact — only + // incoming payments did — so a person you paid first never + // appeared). Create on first pay, then stamp last_paid_at. + let mut contact = + service.store.contact(receiver).unwrap_or_else(|| { + crate::nostr::Contact { + ver: 1, + npub: receiver.clone(), + petname: None, + nip05: None, + nip05_verified_at: None, + relays: relay_hints.clone(), + hue: crate::gui::views::goblin::data::hue_of(receiver) + as u8, + unknown: true, + added_at: crate::nostr::unix_time(), + last_paid_at: None, + blocked: false, + } + }); + contact.last_paid_at = Some(crate::nostr::unix_time()); + contact.unknown = false; + service.store.save_contact(&contact); + // Resolve the recipient's @username so activity + Suggested + // show their name, not a bare npub. service.resolve_contact_identity(receiver); service.set_send_phase(crate::nostr::send_phase::SENT); } @@ -2695,15 +2749,18 @@ async fn handle_task(w: &Wallet, t: WalletTask) { } request.status = crate::nostr::RequestStatus::Approved; service.store.save_request(&request); + service.set_send_phase(crate::nostr::send_phase::SENT); sync_wallet_data(&w, false); } Err(e) => { error!("nostr accept failed: {:?}", e); + service.fail_send(friendly_send_error(&e)); } } } _ => { error!("nostr pay: stored slatepack is not payable"); + service.fail_send("This request is no longer payable.".to_string()); request.status = crate::nostr::RequestStatus::Expired; service.store.save_request(&request); }