use crate::db::MirrorDb; use anyhow::{Context, Result}; use nostr::nips::nip04; use nostr::nips::nip46::{Message, NostrConnectURI, NostrConnectMetadata, Request, ResponseResult}; use nostr_sdk::prelude::*; use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; use tokio::sync::{oneshot, RwLock}; struct Session { npub: PublicKey, client_keys: Keys, signer_pubkey: Option, connected: bool, pairing_uri: String, } struct PendingRequest { tx: oneshot::Sender>, created_at: std::time::Instant, } pub struct Nip46Client { sessions: Arc>>, pending: Arc>>, client: nostr_sdk::Client, relays: Vec, signing_timeout: Duration, db: Arc, } #[derive(Debug, Clone, serde::Serialize)] pub struct Nip46Status { pub npub: String, pub connected: bool, pub pairing_uri: Option, } impl Nip46Client { pub async fn new( relays: Vec, signing_timeout_secs: u64, db: Arc, ) -> Result { let client = nostr_sdk::Client::default(); for url in &relays { let _ = client.add_relay(url).await; } client.connect().await; let sessions = Arc::new(RwLock::new(HashMap::new())); let pending = Arc::new(RwLock::new(HashMap::new())); Ok(Self { sessions, pending, client, relays, signing_timeout: Duration::from_secs(signing_timeout_secs), db, }) } pub async fn init_sessions(&self, npubs: &[PublicKey]) -> Result<()> { let records = self.db.get_all_nip46_sessions().await?; let existing: HashMap = records .into_iter() .map(|r| (r.npub, r)) .collect(); for pk in npubs { let pk_hex = pk.to_hex(); let session = if let Some(rec) = existing.get(&pk_hex) { let client_keys = Keys::parse(&rec.client_secret).context("invalid stored client secret")?; let signer_pk = rec .signer_pubkey .as_ref() .map(|s| PublicKey::from_hex(s)) .transpose() .context("invalid stored signer pubkey")?; let pairing_uri = Self::build_pairing_uri(&client_keys, &self.relays); Session { npub: *pk, client_keys, signer_pubkey: signer_pk, connected: rec.connected && signer_pk.is_some(), pairing_uri, } } else { let client_keys = Keys::generate(); let pairing_uri = Self::build_pairing_uri(&client_keys, &self.relays); self.db .upsert_nip46_session(&pk_hex, &client_keys.secret_key()?.to_hex(), None, false) .await?; Session { npub: *pk, client_keys, signer_pubkey: None, connected: false, pairing_uri, } }; tracing::info!( npub = %pk_hex, connected = session.connected, has_signer = session.signer_pubkey.is_some(), "NIP-46 session initialized" ); self.sessions.write().await.insert(pk_hex, session); } Ok(()) } pub async fn start_listener(&self) { let sessions = self.sessions.read().await; let client_pubkeys: Vec = sessions .values() .map(|s| s.client_keys.public_key()) .collect(); drop(sessions); if client_pubkeys.is_empty() { tracing::warn!("no NIP-46 sessions to listen for"); return; } let filter = Filter::new() .kind(Kind::Custom(24133)) .pubkeys(client_pubkeys); let _ = self .client .subscribe(filter, None) .await .map_err(|e| tracing::error!(error = %e, "failed to subscribe for NIP-46 responses")); let sessions = self.sessions.clone(); let pending = self.pending.clone(); let db = self.db.clone(); let client = self.client.clone(); tokio::spawn(async move { let _ = client .handle_notifications(|notification| { let sessions = sessions.clone(); let pending = pending.clone(); let db = db.clone(); async move { if let RelayPoolNotification::Event { event, .. } = notification { if event.kind == Kind::Custom(24133) { let _ = Self::handle_response(&sessions, &pending, &db, &event).await; } } Ok(false) } }) .await .map_err(|e| tracing::error!(error = %e, "NIP-46 listener exited")); }); tracing::info!("NIP-46 response listener started"); } async fn handle_response( sessions: &Arc>>, pending: &Arc>>, db: &Arc, event: &Event, ) -> Result<()> { let sessions_guard = sessions.read().await; let session = sessions_guard .values() .find(|s| s.client_keys.public_key() == event.pubkey); let session = match session { Some(s) => s, None => return Ok(()), }; let decrypted = nip04::decrypt( session.client_keys.secret_key()?, &event.pubkey, &event.content, ) .context("failed to decrypt NIP-46 response")?; let message: Message = Message::from_json(&decrypted) .context("failed to parse NIP-46 message")?; match &message { Message::Response { id, result, error } => { if let Some(err) = error { tracing::error!(id = %id, error = %err, "NIP-46 response error"); let mut pending_guard = pending.write().await; if let Some(p) = pending_guard.remove(id) { let _ = p.tx.send(Err(anyhow::anyhow!("signer error: {}", err))); } return Ok(()); } if let Some(result) = result { match result { ResponseResult::GetPublicKey(pk) => { let pk_hex = session.npub.to_hex(); let signer_hex = pk.to_hex(); tracing::info!( npub = %pk_hex, signer = %signer_hex, "NIP-46 session connected — learned signer pubkey" ); drop(sessions_guard); let mut sessions_guard = sessions.write().await; if let Some(s) = sessions_guard.get_mut(&pk_hex) { s.signer_pubkey = Some(*pk); s.connected = true; } let _ = db .upsert_nip46_session( &pk_hex, &session.client_keys.secret_key()?.to_hex(), Some(&signer_hex), true, ) .await; } _ => { let mut pending_guard = pending.write().await; if let Some(p) = pending_guard.remove(id) { let _ = p.tx.send(Ok(result.clone())); } } } } } Message::Request { id, req } => { tracing::debug!(id = %id, method = %req.method(), "received NIP-46 request from signer"); } } Ok(()) } pub async fn connect_session(&self, npub: &PublicKey) -> Result<()> { let sessions = self.sessions.read().await; let pk_hex = npub.to_hex(); let session = sessions .get(&pk_hex) .context("no NIP-46 session for npub")?; let connect_req = Request::Connect { public_key: *npub, secret: None, }; self.send_request_to_signer(&session, connect_req).await?; tracing::info!(npub = %pk_hex, "sent connect request to signer"); Ok(()) } pub async fn sign_event(&self, npub: &PublicKey, unsigned: &UnsignedEvent) -> Result { let sessions = self.sessions.read().await; let pk_hex = npub.to_hex(); let session = sessions .get(&pk_hex) .context("no NIP-46 session for npub")?; if !session.connected || session.signer_pubkey.is_none() { anyhow::bail!("NIP-46 session not connected — pair with signer first"); } let result = self .send_request_to_signer(&session, Request::SignEvent(unsigned.clone())) .await?; match result { ResponseResult::SignEvent(signed) => Ok(*signed), other => anyhow::bail!("unexpected NIP-46 response: {:?}", other), } } async fn send_request_to_signer( &self, session: &Session, request: Request, ) -> Result { let signer_pubkey = session .signer_pubkey .context("no signer pubkey — not paired")?; let message = Message::request(request); let message_json = message.as_json(); let encrypted = nip04::encrypt(session.client_keys.secret_key()?, &signer_pubkey, &message_json)?; let event_builder = EventBuilder::new( Kind::Custom(24133), &encrypted, [Tag::public_key(signer_pubkey)], ); let event = event_builder.sign_with_keys(&session.client_keys)?; let request_id = message.id().to_string(); let (tx, rx) = oneshot::channel(); { let mut pending = self.pending.write().await; pending.insert( request_id.clone(), PendingRequest { tx, created_at: std::time::Instant::now(), }, ); } self.client.send_event(event).await?; let timeout = self.signing_timeout; let result = tokio::time::timeout(timeout, async { match rx.await { Ok(Ok(response)) => Ok(response), Ok(Err(e)) => Err(e), Err(_) => Err(anyhow::anyhow!("signing request channel dropped")), } }) .await .map_err(|_| { { let pending_id = request_id.clone(); async move { let mut pending = self.pending.write().await; pending.remove(&pending_id); } } anyhow::anyhow!( "NIP-46 signing request timed out after {}s", timeout.as_secs() ) })??; Ok(result) } pub async fn get_status(&self) -> Vec { let sessions = self.sessions.read().await; sessions .values() .map(|s| Nip46Status { npub: s.npub.to_hex(), connected: s.connected, pairing_uri: if !s.connected { Some(s.pairing_uri.clone()) } else { None }, }) .collect() } pub async fn get_pairing_uri(&self, npub: &PublicKey) -> Option { let sessions = self.sessions.read().await; sessions .get(&npub.to_hex()) .map(|s| s.pairing_uri.clone()) } fn build_pairing_uri(client_keys: &Keys, relays: &[String]) -> String { let relay_urls: Vec = relays .iter() .filter_map(|r| RelayUrl::parse(r).ok()) .collect(); let uri = NostrConnectURI::client( client_keys.public_key(), relay_urls, "grasp-mirror", ); uri.to_string() } }