//! Bitcoin domain names management using Nostr NIP-05 verification. //! Allows users to register human-readable names linked to their DIDs //! and verify names of peers via the NIP-05 protocol. use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use std::path::Path; use tokio::fs; use tracing::debug; const NAMES_FILE: &str = "names.json"; /// A registered name linked to an identity. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RegisteredName { pub id: String, pub name: String, pub domain: String, pub identity_id: String, pub did: String, pub nostr_pubkey: Option, pub status: NameStatus, pub registered_at: String, pub expires_at: Option, pub nip05: String, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "lowercase")] pub enum NameStatus { Active, Pending, Expired, Failed, } impl std::fmt::Display for NameStatus { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { NameStatus::Active => write!(f, "active"), NameStatus::Pending => write!(f, "pending"), NameStatus::Expired => write!(f, "expired"), NameStatus::Failed => write!(f, "failed"), } } } /// Stored names data. #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct NamesStore { pub names: Vec, } /// NIP-05 verification result. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Nip05Resolution { pub name: String, pub domain: String, pub nostr_pubkey: Option, pub relays: Vec, pub verified: bool, } pub async fn load_names(data_dir: &Path) -> Result { let path = data_dir.join(NAMES_FILE); if !path.exists() { return Ok(NamesStore::default()); } let data = fs::read_to_string(&path) .await .context("Reading names store")?; serde_json::from_str(&data).context("Parsing names store") } pub async fn save_names(data_dir: &Path, store: &NamesStore) -> Result<()> { let path = data_dir.join(NAMES_FILE); let data = serde_json::to_string_pretty(store)?; fs::write(&path, data).await.context("Writing names store") } /// Register a new name linked to an identity. pub async fn register_name( data_dir: &Path, name: &str, domain: &str, identity_id: &str, did: &str, nostr_pubkey: Option<&str>, ) -> Result { let mut store = load_names(data_dir).await?; let nip05 = format!("{}@{}", name, domain); // Check for duplicates if store.names.iter().any(|n| n.nip05 == nip05) { return Err(anyhow::anyhow!("Name {} is already registered", nip05)); } let record = RegisteredName { id: uuid::Uuid::new_v4().to_string(), name: name.to_string(), domain: domain.to_string(), identity_id: identity_id.to_string(), did: did.to_string(), nostr_pubkey: nostr_pubkey.map(|s| s.to_string()), status: NameStatus::Active, registered_at: chrono::Utc::now().to_rfc3339(), expires_at: None, nip05, }; debug!(name = %record.nip05, "Registered new name"); store.names.push(record.clone()); save_names(data_dir, &store).await?; Ok(record) } /// Remove a registered name. pub async fn remove_name(data_dir: &Path, name_id: &str) -> Result<()> { let mut store = load_names(data_dir).await?; let original_len = store.names.len(); store.names.retain(|n| n.id != name_id); if store.names.len() == original_len { return Err(anyhow::anyhow!("Name not found: {}", name_id)); } save_names(data_dir, &store).await } /// Resolve a NIP-05 identifier (user@domain) to verify it. pub async fn resolve_nip05(identifier: &str) -> Result { let parts: Vec<&str> = identifier.split('@').collect(); if parts.len() != 2 { return Err(anyhow::anyhow!( "Invalid NIP-05 identifier: expected user@domain" )); } let name = parts[0]; let domain = parts[1]; let url = format!( "https://{}/.well-known/nostr.json?name={}", domain, name ); let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(10)) .build()?; let response = client.get(&url).send().await; match response { Ok(resp) if resp.status().is_success() => { let body: serde_json::Value = resp.json().await?; let pubkey = body .get("names") .and_then(|names| names.get(name)) .and_then(|v| v.as_str()) .map(|s| s.to_string()); let relays = if let Some(pk) = &pubkey { body.get("relays") .and_then(|r| r.get(pk.as_str())) .and_then(|v| v.as_array()) .map(|arr| { arr.iter() .filter_map(|v| v.as_str().map(|s| s.to_string())) .collect() }) .unwrap_or_default() } else { vec![] }; Ok(Nip05Resolution { name: name.to_string(), domain: domain.to_string(), nostr_pubkey: pubkey.clone(), relays, verified: pubkey.is_some(), }) } Ok(resp) => Err(anyhow::anyhow!( "NIP-05 verification failed: HTTP {}", resp.status() )), Err(_) => Ok(Nip05Resolution { name: name.to_string(), domain: domain.to_string(), nostr_pubkey: None, relays: vec![], verified: false, }), } } /// Link a registered name to a DID by updating its identity association. pub async fn link_name_to_did( data_dir: &Path, name_id: &str, did: &str, identity_id: &str, ) -> Result { let mut store = load_names(data_dir).await?; let name = store .names .iter_mut() .find(|n| n.id == name_id) .ok_or_else(|| anyhow::anyhow!("Name not found: {}", name_id))?; name.did = did.to_string(); name.identity_id = identity_id.to_string(); let updated = name.clone(); save_names(data_dir, &store).await?; Ok(updated) }