fix(profiles): never treat placeholder kind-0 as a real display name

An early build auto-published the empty-label default 'My Profile' as
new accounts' kind-0 metadata. That poisoned kind-0 then propagated
back: pairing-time enrichment and the background backfill both copied
it over the name the user typed, so profiles like npub1p437… display
'My Profile' forever and the loop can never resolve a 'real' name.

- network_display_name(): shared resolver that rejects blank AND
  placeholder names fetched from the network (unit-tested)
- create_profile / import_profile: never auto-publish a generic
  placeholder as kind-0; placeholders stay local until the user names
  the profile, and import falls back to the shortened npub
- backfill + pairing enrichment now use the shared resolver
This commit is contained in:
Avi 2026-10-01 10:48:22 -05:00
commit 83f594074b
3 changed files with 56 additions and 15 deletions

View file

@ -423,12 +423,7 @@ pub async fn serve() -> Result<(), AppError> {
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());
let real_name = profiles::network_display_name(&meta);
if let Some(name) = real_name {
if profiles::is_generic_pairing_label(&row.label) {
row.label = name.to_string();

View file

@ -87,8 +87,14 @@ pub fn create_profile(
// Publish kind 0 metadata event so other clients can see the username/display name.
// Best-effort: relay failures here never block profile creation.
//
// Generic placeholders are never published: an early build auto-published
// the empty-label default "My Profile" as the account's kind-0, and that
// poisoned metadata then became the "real" name every client (including
// this app's own backfill) resolved forever. A placeholder stays local
// until the user names the profile, and only then goes on the network.
let relay_urls = relays::enabled_urls(settings);
if !relay_urls.is_empty() {
if !relay_urls.is_empty() && !is_generic_pairing_label(&label) {
publish_metadata_blocking(&keys, &label, None, None, relay_urls);
}
@ -142,6 +148,9 @@ pub fn import_profile(
.as_ref()
.and_then(|m| m.display_name.as_deref().or(m.name.as_deref()))
.filter(|name| !name.trim().is_empty())
// Placeholder kind-0 (see create_profile) is not a name; fall
// back to the shortened npub instead of importing "My Profile".
.filter(|name| !is_generic_pairing_label(name.trim()))
.map(str::to_string)
.unwrap_or_else(|| shorten_npub(&keys.public_key()))
} else {
@ -162,7 +171,7 @@ pub fn import_profile(
});
let relay_urls = relays::enabled_urls(settings);
if !relay_urls.is_empty() {
if !relay_urls.is_empty() && !is_generic_pairing_label(&label) {
publish_metadata_blocking(
&keys,
&label,
@ -566,6 +575,23 @@ pub fn is_generic_pairing_label(label: &str) -> bool {
GENERIC_PAIRING_LABELS.contains(&label)
}
/// The real display name carried by fetched kind-0 metadata, if any.
///
/// Prefers `display_name` over `name`, rejects blanks, and rejects generic
/// placeholders: early builds published the empty-label default "My Profile"
/// as kind-0, so a placeholder arriving FROM the network is pollution, not a
/// name, and must never be copied over a profile row's label.
pub fn network_display_name(metadata: &Metadata) -> Option<String> {
metadata
.display_name
.as_deref()
.or(metadata.name.as_deref())
.map(str::trim)
.filter(|name| !name.is_empty())
.filter(|name| !is_generic_pairing_label(name))
.map(str::to_string)
}
/// 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
@ -959,6 +985,26 @@ mod tests {
assert!(!is_generic_pairing_label("amber"));
assert!(!is_generic_pairing_label(""));
}
#[test]
fn network_display_name_rejects_placeholder_kind0() {
// The exact pollution case: an early build published "My Profile" as
// the account's kind-0. Resolution must treat it as "no name found",
// never as the profile's display name.
let polluted = Metadata::new()
.name("My Profile")
.display_name("My Profile");
assert_eq!(network_display_name(&polluted), None);
let blank = Metadata::new().name(" ").display_name("");
assert_eq!(network_display_name(&blank), None);
let real = Metadata::new().name("satoshi").display_name("Satoshi ✦");
assert_eq!(network_display_name(&real).as_deref(), Some("Satoshi ✦"));
let name_only = Metadata::new().name("satoshi");
assert_eq!(network_display_name(&name_only).as_deref(), Some("satoshi"));
}
use crate::vault::{KdfParams, Vault, VaultCrypto};
fn populated_vault() -> Vault {

View file

@ -2583,13 +2583,13 @@ impl Nip46ClientSigner {
}
// Upgrade the generic pairing label to the real display
// name only while the row still carries the label we set
// during pairing — a user rename always wins.
let real_name = meta
.display_name
.as_deref()
.or(meta.name.as_deref())
.map(str::trim)
.filter(|name| !name.is_empty());
// during pairing — a user rename always wins. A
// placeholder coming FROM the network is never a name
// (network_display_name rejects it): early builds
// published the empty-label default "My Profile" as
// kind-0, and copying that over the name the user typed
// at pairing is how display names got lost.
let real_name = profiles::network_display_name(&meta);
if let Some(name) = real_name {
if row.label == pairing_label {
row.label = name.to_string();