feat(profiles): persistent identity backfill for generic pairing labels

Pairing-time kind-0 enrichment gives up after ~4 attempts/2 minutes; a
fresh Amber account that has not published metadata yet (or publishes
later) then keeps the generic 'Amber'/'Remote Signer' label forever.
serve() now spawns a slow-cadence backfill: every pass it re-scans for
Nip46Client rows still wearing a GENERIC_PAIRING_LABELS placeholder and
re-fetches kind-0, upgrading label/picture/nip05 when metadata appears.
User renames are never overwritten (placeholder check gates the write);
locked encrypted vaults are skipped; network runs off-lock with a 90s
budget per identity.
This commit is contained in:
Avi 2026-09-28 12:03:35 -05:00
commit 5b60ae3736
2 changed files with 130 additions and 0 deletions

View file

@ -5,6 +5,8 @@ use serde::{Deserialize, Serialize};
use serde_json::json;
use tokio::sync::Mutex;
use nostr_sdk::prelude::PublicKey;
use crate::app::{App, Nip46ClientSignerHandle};
use crate::errors::AppError;
use crate::feed;
@ -331,6 +333,107 @@ pub async fn serve() -> Result<(), AppError> {
let mut tasks = JoinSet::new();
// Persistent identity backfill. Pairing-time enrichment tries for ~2
// minutes and stops; if the account's kind-0 was never published (or was
// published later), the row would keep its generic "Amber"/"Remote
// Signer" placeholder forever — which is what a new user with a fresh
// Amber account sees, with no way to know the app resolved nothing.
// This loop retries on a slow cadence while the backend runs: every pass
// collects remote rows still wearing a generic placeholder (only once
// the vault is unlocked — an encrypted vault hides them otherwise),
// fetches kind-0 from the enabled relays, and upgrades the row. A user
// rename always wins: is_generic_pairing_label is the sole gate on the
// label write. Cheap when nothing is pending (one vault scan per pass).
{
let app = app.clone();
tasks.spawn(async move {
loop {
// Collect pending identities without holding the lock
// across network work.
let pending: Vec<(String, PublicKey)> = {
let guard = app.lock().await;
if guard.is_locked() {
Vec::new()
} else {
guard
.vault
.profiles
.iter()
.filter(|p| {
p.signer_mode == SignerMode::Nip46Client
&& profiles::is_generic_pairing_label(&p.label)
})
.filter_map(|p| {
PublicKey::parse(&p.public_key)
.ok()
.map(|key| (p.public_key.clone(), key))
})
.collect()
}
};
if pending.is_empty() {
tokio::time::sleep(Duration::from_secs(300)).await;
continue;
}
let relay_urls = {
let guard = app.lock().await;
relays::enabled_urls(&guard.settings)
};
for (npub, identity) in pending {
let found = tokio::time::timeout(
Duration::from_secs(90),
tokio::task::spawn_blocking({
let relay_urls = relay_urls.clone();
move || profiles::fetch_profile_metadata(&identity, &relay_urls)
}),
)
.await
.ok()
.and_then(|join| join.ok())
.flatten();
let Some(meta) = found else { continue };
let mut guard = app.lock().await;
let mut changed = false;
// Re-resolve by pubkey string: another request (rename,
// a pairing-time enrichment) may have edited the row
// while this fetch was in flight.
if let Some(row) = guard
.vault
.profiles
.iter_mut()
.find(|p| p.public_key == npub)
{
if row.picture.is_none() && meta.picture.is_some() {
row.picture = meta.picture.clone();
changed = true;
}
if row.nip05.is_none() && meta.nip05.is_some() {
row.nip05 = meta.nip05.clone();
changed = true;
}
let real_name = meta
.display_name
.as_deref()
.or(meta.name.as_deref())
.map(str::trim)
.filter(|name| !name.is_empty());
if let Some(name) = real_name {
if profiles::is_generic_pairing_label(&row.label) {
row.label = name.to_string();
changed = true;
}
}
}
if changed {
let _ = guard.save_vault();
eprintln!("[backfill] resolved profile identity via kind-0: {npub}");
}
}
tokio::time::sleep(Duration::from_secs(120)).await;
}
});
}
while let Some(line) = line_rx.recv().await {
if line.trim().is_empty() {
continue;

View file

@ -553,6 +553,19 @@ pub fn find_stored_profile<'a>(
.ok_or_else(|| AppError::profile_not_found(npub))
}
/// Labels the UI assigns when pairing without a user-supplied name
/// (CreateProfileModal defaults to "Amber"; Signer Mode to "Remote
/// Signer"). A remote-signer profile row still carrying one of these has
/// never resolved its real identity from the network, so it stays a
/// candidate for automatic name backfill on every launch. A user rename
/// always falls outside this list and is therefore never overwritten.
pub const GENERIC_PAIRING_LABELS: &[&str] = &["Amber", "Remote Signer"];
/// True when `label` is one of the generic pairing placeholders.
pub fn is_generic_pairing_label(label: &str) -> bool {
GENERIC_PAIRING_LABELS.contains(&label)
}
/// Create or refresh the vault profile for a remote (NIP-46) identity.
///
/// When a NIP-46 client connection is established the identity lives on the
@ -930,6 +943,20 @@ pub fn delete_profile(vault: &mut Vault, npub: &str) -> Result<ProfileSummary, A
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn generic_pairing_labels_gate_the_backfill() {
// The background backfill upgrades a row's label ONLY while it still
// wears one of the placeholders the UI assigns at pairing time.
assert!(is_generic_pairing_label("Amber"));
assert!(is_generic_pairing_label("Remote Signer"));
// Any real name — fetched or user-typed — is never a candidate, so
// automatic enrichment can never overwrite it.
assert!(!is_generic_pairing_label("satoshi"));
assert!(!is_generic_pairing_label("My Profile"));
assert!(!is_generic_pairing_label("amber"));
assert!(!is_generic_pairing_label(""));
}
use crate::vault::{KdfParams, Vault, VaultCrypto};
fn populated_vault() -> Vault {