use std::collections::HashMap; use std::time::Duration; use anyhow::{Error, anyhow}; use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task}; use nostr::event::IntoEventBuilder; use nostr_connect::prelude::*; use nostr_sdk::client::SyncSummary; use nostr_sdk::prelude::*; use signed_core::filters; use signed_nostr::{SignedAuthUrlHandler, UniversalSigner, Update}; /// Keyring entry holding the user credential (`nsec1...` or `bunker://...` /// with an embedded `?master=` NIP-46 session key). pub const USER_KEYRING: &str = "Signed Safe Storage"; /// Timeout for NIP-46 signer responses. pub const NOSTR_CONNECT_TIMEOUT: u64 = 60; /// Relays connected at startup, before any user-specific relay config is known. pub const BOOTSTRAP_RELAYS: [&str; 4] = [ "wss://relay.primal.net", "wss://relay.ditto.pub", "wss://index.ngit.dev", "wss://profiles.nostr1.com", ]; /// Relays used for indexing user's relay list (NIP-65). pub const INDEXER_RELAYS: [&str; 3] = [ "wss://indexer.coracle.social", "wss://purplepag.es", "wss://user.kindpag.es", ]; #[derive(Debug, Clone)] pub enum BackendEvent { /// User has no signer configured. SignerRequired, /// The stored identity is NIP-49 encrypted (`ncryptsec1...`); a /// passphrase is required to decrypt it before the session can resume. PassphraseRequired, /// The signer has changed (login/logout/account switch). SignerChanged, /// Relay bootstrap finished. Connected, /// A new event was received from a relay and stored in the database. NostrUpdate(Update), /// A negentropy sync completed; the database was updated directly, /// so stores should re-query (no [`BackendEvent::NostrUpdate`] is fired /// for synced events). Synced, /// A negentropy sync is in flight. Stores may re-query to render /// incrementally; UI can show `current`/`total` progress. SyncProgress { /// Total events to process. total: u64, /// Events processed so far. current: u64, }, /// An event built locally was signed, broadcast and stored. Published(Box), /// An error occurred. Error(String), } impl BackendEvent { pub fn error(error: T) -> Self where T: Into, { Self::Error(error.into()) } } /// Global backend entity: owns the nostr client, the signer and the /// notification pump. Stores subscribe to [`BackendEvent`] and re-query the /// local database when relevant updates arrive. pub struct Backend { client: Client, signer: UniversalSigner, current_user: Option, connected: bool, sync_progress: Option<(u64, u64)>, /// Whether the stored credential is NIP-49 encrypted and a passphrase /// is still needed to resume the session. passphrase_required: bool, tasks: Vec>>, } struct GlobalBackend(Entity); impl Global for GlobalBackend {} impl EventEmitter for Backend {} impl Backend { /// Retrieve the global backend. pub fn global(cx: &App) -> Entity { cx.global::().0.clone() } pub(crate) fn set_global(entity: Entity, cx: &mut App) { cx.set_global(GlobalBackend(entity)); } pub(crate) fn new(client: Client, signer: UniversalSigner, cx: &mut Context) -> Self { let pump_client = client.clone(); let pump = cx.spawn(async move |this, cx| { let mut notifications = pump_client.notifications(); while let Some(notification) = notifications.next().await { let ClientNotification::Event { event, .. } = notification else { continue; }; let update = Update::from_event(&event); if this .update(cx, |_, cx| cx.emit(BackendEvent::NostrUpdate(update))) .is_err() { break; } } Ok(()) }); let mut this = Self { client, signer, current_user: None, connected: false, sync_progress: None, passphrase_required: false, tasks: vec![pump], }; this.bootstrap(cx); this } /// Bootstrap the client: connect to the default relays (indexers as /// discovery-only) and restore the saved session, if any. fn bootstrap(&mut self, cx: &mut Context) { let client = self.client.clone(); let task = cx.background_spawn(async move { for url in BOOTSTRAP_RELAYS { client.add_relay(url).await?; } for url in INDEXER_RELAYS { client .add_relay(url) .capabilities(RelayCapabilities::DISCOVERY) .await?; } client.connect().await; Ok::<(), Error>(()) }); self.tasks.push(cx.spawn(async move |this, cx| { match task.await { Ok(()) => { this.update(cx, |this, cx| { this.connected = true; cx.emit(BackendEvent::Connected); cx.notify(); })?; } Err(e) => { this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?; } } Ok(()) })); self.restore_session(cx); } /// Restore the saved session from the keyring. Emits /// [`BackendEvent::SignerRequired`] if no credential is stored, or /// [`BackendEvent::PassphraseRequired`] if the stored identity is /// NIP-49 encrypted. pub fn restore_session(&mut self, cx: &mut Context) { if cfg!(target_arch = "wasm32") { cx.emit(BackendEvent::SignerRequired); return; } let user = cx.read_credentials(USER_KEYRING); self.tasks.push(cx.spawn(async move |this, cx| { let content = match user.await { Ok(Some((_username, secret))) => String::from_utf8(secret)?, _ => { this.update(cx, |_, cx| cx.emit(BackendEvent::SignerRequired))?; return Ok(()); } }; let result = async { if content.starts_with("nsec1") { let keys = Keys::new(SecretKey::parse(&content)?); this.update(cx, |this, cx| this.set_signer(keys, cx))?; } else if content.starts_with("bunker://") { let (base, keys) = extract_master_key(&content); let uri = NostrConnectUri::parse(base)?; let mut signer = NostrConnect::new( uri, keys, Duration::from_secs(NOSTR_CONNECT_TIMEOUT), None, )?; signer.auth_url_handler(SignedAuthUrlHandler); this.update(cx, |this, cx| this.set_signer(signer, cx))?; } else if content.starts_with("ncryptsec1") { // Encrypted identity: a passphrase is required to // decrypt it before the session can resume. log::warn!("stored identity is ncryptsec-encrypted; waiting for passphrase"); this.update(cx, |this, cx| { this.passphrase_required = true; cx.emit(BackendEvent::PassphraseRequired); })?; } else { this.update(cx, |_, cx| cx.emit(BackendEvent::SignerRequired))?; } Ok::<_, Error>(()) } .await; if let Err(e) = result { this.update(cx, |_, cx| { cx.emit(BackendEvent::error(e.to_string())); cx.emit(BackendEvent::SignerRequired); })?; } Ok(()) })); } /// Decrypt the NIP-49 encrypted credential stored in the keyring with /// the given passphrase and resume the session. /// /// The scrypt decryption runs off the UI thread. The returned task /// yields the public key on success, or the failure reason (e.g. wrong /// passphrase), so callers can render inline errors. pub fn restore_with_passphrase( &mut self, password: &str, cx: &mut Context, ) -> Task> { let password = password.to_owned(); let user = cx.read_credentials(USER_KEYRING); cx.spawn(async move |this, cx| { let content = user .await? .map(|(_username, secret)| String::from_utf8(secret)) .transpose()? .ok_or_else(|| anyhow!("no stored credential; nothing to unlock"))?; if !content.starts_with("ncryptsec1") { return Err(anyhow!("stored credential is not passphrase-encrypted")); } let decrypt_task = cx.background_spawn(async move { let encrypted = EncryptedSecretKey::from_bech32(&content)?; let secret = encrypted.decrypt(&password)?; Ok::<_, Error>(Keys::new(secret)) }); let keys = decrypt_task.await?; let public_key = keys.public_key(); this.update(cx, |this, cx| this.set_signer(keys, cx))?; Ok(public_key) }) } /// Create a new identity: generate keys, encrypt the secret key with the /// passphrase (NIP-49) and persist it in the keyring, then publish the /// user's NIP-65 relay list, metadata and grasp list. /// /// The heavy encryption runs off the UI thread. The returned task yields /// the new public key on success, or the failure reason, so callers can /// render progress and inline errors. pub fn create_identity( &mut self, name: &str, password: &str, cx: &mut Context, ) -> Task> { let name = name.trim().to_owned(); let password = password.to_owned(); if name.is_empty() || name.len() > 255 { return Task::ready(Err(anyhow!("Name must be 1-255 characters"))); } if password.is_empty() { return Task::ready(Err(anyhow!("Passphrase must not be empty"))); } cx.spawn(async move |this, cx| { let job = cx.background_spawn(async move { let keys = Keys::generate(); let encrypted = EncryptedSecretKey::new(keys.secret_key(), &password, 16, KeySecurity::Medium)?; let ncryptsec = encrypted.to_bech32()?; Ok::<_, Error>((keys, ncryptsec)) }); let (keys, ncryptsec) = job.await?; let public_key = keys.public_key(); // Persist the encrypted credential. let write = cx.update(|cx| { cx.write_credentials(USER_KEYRING, &public_key.to_hex(), ncryptsec.as_bytes()) }); write.await?; this.update(cx, |this, cx| { // Become the new identity, so the publishes below are // signed with the new keys. this.signer.swap_inner(keys); this.current_user = Some(public_key); this.bootstrap_user(public_key, cx); cx.emit(BackendEvent::SignerChanged); cx.notify(); let relays: Vec<(RelayUrl, Option)> = [ ( RelayUrl::parse("wss://relay.primal.net").unwrap(), Some(RelayMetadata::Read), ), ( RelayUrl::parse("wss://relay.ditto.pub").unwrap(), Some(RelayMetadata::Read), ), ( RelayUrl::parse("wss://relay.nostr.net").unwrap(), Some(RelayMetadata::Write), ), ( RelayUrl::parse("wss://nos.lol").unwrap(), Some(RelayMetadata::Write), ), ] .to_vec(); this.send_fire_and_forget(RelayList::new(relays).into_event_builder(), cx); let metadata = Metadata::new() .name(&name) .display_name(&name) .into_event_builder(); this.send_fire_and_forget(metadata, cx); let grasp_servers: Vec = ["wss://gitnostr.com", "wss://relay.ngit.dev"] .into_iter() .map(|url| RelayUrl::parse(url).expect("valid relay URL")) .collect(); this.send_fire_and_forget( GitUserGraspList { grasp_servers }.into_event_builder(), cx, ); })?; Ok(public_key) }) } /// Login with an `nsec1...` key or a `bunker://...` URI, dispatching on /// the credential's prefix. pub fn login(&mut self, credential: &str, cx: &mut Context) { let credential = credential.trim(); if credential.starts_with("nsec1") { self.login_with_nsec(credential, cx); } else if credential.starts_with("bunker://") { self.login_with_bunker(credential, cx); } else { cx.emit(BackendEvent::error( "Unsupported credential, expected nsec1... or bunker://...", )); } } /// Create a fresh identity and login with it. The generated key is /// persisted in the keyring like any other `nsec` credential. pub fn login_with_new_identity(&mut self, cx: &mut Context) { let nsec = Keys::generate() .secret_key() .to_bech32() .expect("infallible"); self.login_with_nsec(&nsec, cx); } /// Login with an `nsec1...` secret key. The credential is verified by /// the signer flow and persisted in the keyring. pub fn login_with_nsec(&mut self, nsec: &str, cx: &mut Context) { let nsec = nsec.trim().to_owned(); let keys = match SecretKey::parse(&nsec) { Ok(secret) => Keys::new(secret), Err(e) => { cx.emit(BackendEvent::error(e.to_string())); return; } }; let write = cx.write_credentials(USER_KEYRING, &keys.public_key().to_hex(), nsec.as_bytes()); self.tasks.push(cx.spawn(async move |this, cx| { if let Err(e) = write.await { this.update(cx, |_, cx| cx.emit(BackendEvent::error(e.to_string())))?; return Ok(()); } this.update(cx, |this, cx| this.set_signer(keys, cx))?; Ok(()) })); } /// Login with a `bunker://...` URI (NIP-46). A fresh session key is /// generated and embedded into the stored URI as `?master=`, so /// no separate keyring entry is needed. The auth URL, if any, is opened /// in the default browser. The credential is persisted in the keyring /// after the signer proves reachable. pub fn login_with_bunker(&mut self, uri: &str, cx: &mut Context) { let uri_string = uri.trim().to_owned(); let connect_uri = match NostrConnectUri::parse(&uri_string) { Ok(uri) => uri, Err(e) => { cx.emit(BackendEvent::error(e.to_string())); return; } }; let keys = Keys::generate(); let credential = with_master_key(&uri_string, &keys); let write = cx.write_credentials(USER_KEYRING, "bunker", credential.as_bytes()); self.tasks.push(cx.spawn(async move |this, cx| { let result = async { let mut signer = NostrConnect::new( connect_uri, keys, Duration::from_secs(NOSTR_CONNECT_TIMEOUT), None, )?; signer.auth_url_handler(SignedAuthUrlHandler); // Verify the signer before persisting the credential. signer.get_public_key_async().await?; write.await?; this.update(cx, |this, cx| this.set_signer(signer, cx))?; Ok::<_, Error>(()) } .await; if let Err(e) = result { this.update(cx, |_, cx| cx.emit(BackendEvent::error(e.to_string())))?; } Ok(()) })); } /// Remove the saved credential and reset to an anonymous session. pub fn logout(&mut self, cx: &mut Context) { let delete = cx.delete_credentials(USER_KEYRING); self.tasks.push(cx.spawn(async move |this, cx| { delete.await.ok(); this.update(cx, |this, cx| { this.signer.swap_inner(Keys::generate()); this.current_user = None; this.passphrase_required = false; cx.emit(BackendEvent::SignerChanged); cx.emit(BackendEvent::SignerRequired); cx.notify(); })?; Ok(()) })); } /// Fetch the user's grasp list (kind `10317`) and add the listed grasp /// servers as relays. fn bootstrap_user(&mut self, public_key: PublicKey, cx: &mut Context) { let client = self.client.clone(); self.tasks.push(cx.spawn(async move |this, cx| { let result = async { let events = client.fetch_events(filters::grasp_list(public_key)).await?; let urls: Vec = events .into_iter() .max_by_key(|e| e.created_at) .map(|e| { e.tags .iter() .filter(|t| t.kind() == "g") .filter_map(|t| t.content().map(str::to_owned)) .collect() }) .unwrap_or_default(); for url in urls { client.add_relay(&url).await.ok(); } client.connect().await; Ok::<_, Error>(()) } .await; if let Err(e) = result { this.update(cx, |_, cx| cx.emit(BackendEvent::error(e.to_string())))?; } Ok(()) })); } /// Get the nostr client. pub fn client(&self) -> Client { self.client.clone() } /// Get the current signer. pub fn signer(&self) -> UniversalSigner { self.signer.clone() } /// Get the current user's public key. pub fn current_user(&self) -> Option { self.current_user } /// Whether the stored credential is NIP-49 encrypted and a passphrase /// is still needed to resume the session. pub fn passphrase_required(&self) -> bool { self.passphrase_required } /// Surface an error message through [`BackendEvent::Error`]. pub fn emit_error(&mut self, message: impl Into, cx: &mut Context) { cx.emit(BackendEvent::error(message)); } /// Whether the relay bootstrap has completed. pub fn is_connected(&self) -> bool { self.connected } /// Progress of the in-flight negentropy sync, if any: `(total, current)`. pub fn sync_progress(&self) -> Option<(u64, u64)> { self.sync_progress } /// Update the signer (any type implementing the async signer traits, /// e.g. `Keys`, `NostrConnect`, a browser extension proxy). pub fn set_signer(&mut self, new_signer: T, cx: &mut Context) where T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + 'static, ::Error: std::error::Error + Send + Sync + 'static, ::Error: std::error::Error + Send + Sync + 'static, ::Error: std::error::Error + Send + Sync + 'static, { let task = cx.spawn(async move |this, cx| { match new_signer.get_public_key_async().await { Ok(public_key) => { this.update(cx, |this, cx| { this.signer.swap_inner(new_signer); this.current_user = Some(public_key); this.passphrase_required = false; this.bootstrap_user(public_key, cx); cx.emit(BackendEvent::SignerChanged); cx.notify(); })?; } Err(e) => { this.update(cx, |_this, cx| { cx.emit(BackendEvent::error(e.to_string())); })?; } } Ok(()) }); self.tasks.push(task); } /// Add relays and connect to them. pub fn add_relays(&mut self, urls: Vec, cx: &mut Context) { let client = self.client.clone(); let task = cx.background_spawn(async move { for url in urls { client.add_relay(&url).await?; } client.connect().await; Ok::<(), Error>(()) }); self.tasks.push(cx.spawn(async move |this, cx| { match task.await { Ok(()) => { this.update(cx, |this, cx| { this.connected = true; cx.emit(BackendEvent::Connected); cx.notify(); })?; } Err(e) => { this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?; } } Ok(()) })); } /// Add relays used only for discovery (e.g. NIP-65 indexers) and /// connect to them. No subscriptions or writes are routed through them. pub fn add_discovery_relays(&mut self, urls: Vec, cx: &mut Context) { let client = self.client.clone(); let task = cx.background_spawn(async move { for url in urls { client .add_relay(&url) .capabilities(RelayCapabilities::DISCOVERY) .await?; } client.connect().await; Ok::<(), Error>(()) }); self.tasks.push(cx.spawn(async move |this, cx| { if let Err(e) = task.await { this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?; } Ok(()) })); } /// Start a persistent subscription. Matching events are stored in the /// database automatically and surface as [`BackendEvent::NostrUpdate`]. pub fn subscribe(&mut self, filter: Filter, cx: &mut Context) { let client = self.client.clone(); let task = cx.background_spawn(async move { client.subscribe(filter).await.map(|_| ()) }); self.tasks.push(cx.spawn(async move |this, cx| { if let Err(e) = task.await { this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?; } Ok(()) })); } /// Connect to relays announced by a repository (NIP-34 `relays` tag) and /// fetch its events from them: a one-shot auto-closing subscription for /// `filters`, plus a negentropy sync so issues, patches and PRs stored /// only on those relays are not missed. /// /// Best-effort: failures are logged, not surfaced, because the bootstrap /// relays already cover the repository. The relays stay in the pool, so /// events the user publishes for this repository also reach them. pub fn connect_repo_relays( &mut self, relays: Vec, filters: Vec, cx: &mut Context, ) { let client = self.client.clone(); self.tasks.push(cx.spawn(async move |_this, _cx| { if let Err(e) = connect_repo_relays_only(&client, relays, filters).await { log::warn!("repo relay fetch failed: {e}"); } Ok(()) })); } /// Start a one-shot subscription targeted only at the bootstrap relays, /// auto-closing after EOSE or a short timeout. Matching events are stored /// in the database and surface as [`BackendEvent::NostrUpdate`] while the /// subscription is open. pub fn subscribe_bootstrap(&mut self, filters: Vec, cx: &mut Context) { let client = self.client.clone(); let task = cx.background_spawn(async move { subscribe_bootstrap_only(&client, filters).await }); self.tasks.push(cx.spawn(async move |this, cx| { if let Err(e) = task.await { this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?; } Ok(()) })); } /// Negentropy-sync the given filter against the bootstrap relays: /// reconciles the local database with the relays in both directions. /// Emits [`BackendEvent::SyncProgress`] while running (throttled to /// whole-percent changes) and [`BackendEvent::Synced`] on completion. pub fn sync_bootstrap(&mut self, filter: Filter, cx: &mut Context) { let client = self.client.clone(); self.sync_progress = Some((0, 0)); cx.notify(); let (tx, mut rx) = SyncProgress::channel(); self.tasks.push(cx.spawn(async move |this, cx| { let mut last_percent: u64 = 0; while rx.changed().await.is_ok() { let progress = *rx.borrow_and_update(); let percent = (progress.percentage() * 100.0) as u64; if progress.current > 0 && percent != last_percent { last_percent = percent; let alive = this.update(cx, |this, cx| { this.sync_progress = Some((progress.total, progress.current)); cx.emit(BackendEvent::SyncProgress { total: progress.total, current: progress.current, }); cx.notify(); }); if alive.is_err() { break; } } } Ok(()) })); let task = cx.background_spawn(async move { let opts = SyncOptions::default().progress(tx); sync_bootstrap_only(&client, filter, opts).await }); self.tasks.push(cx.spawn(async move |this, cx| { match task.await { Ok(summary) => { log::debug!( "sync done: {} received, {} sent", summary.received.len(), summary.sent.len() ); this.update(cx, |this, cx| { this.sync_progress = None; cx.emit(BackendEvent::Synced); cx.notify(); })?; } Err(e) => { this.update(cx, |this, cx| { this.sync_progress = None; cx.emit(BackendEvent::error(e.to_string())) })?; } } Ok(()) })); } /// Sign, broadcast and locally store an event. Emits /// [`BackendEvent::Published`] on success so stores can refresh. /// /// The returned task yields the outcome of this specific action, so /// callers can show inline progress/errors instead of relying on /// the global [`BackendEvent::Error`]. The task is owned by the caller; /// dropping it cancels the publish. pub fn send( &mut self, builder: EventBuilder, cx: &mut Context, ) -> Task> { let client = self.client.clone(); let signer = self.signer.clone(); cx.spawn(async move |this, cx| { // Sign with the current signer, broadcast, and save locally so // the event is immediately visible to database queries. let work = cx.background_spawn(async move { let event = builder.finalize_async(&signer).await?; let output = client.send_event(&event).await?; if output.success.is_empty() && !output.failed.is_empty() { let reasons = output .failed .values() .cloned() .collect::>() .join(", "); return Err(anyhow!("event not accepted by any relay: {reasons}")); } Ok(event) }); let result = work.await; match &result { Ok(event) => { this.update(cx, |_this, cx| { cx.emit(BackendEvent::Published(Box::new(event.clone()))); }) .ok(); } Err(e) => { this.update(cx, |_this, cx| { cx.emit(BackendEvent::error(e.to_string())); }) .ok(); } } result }) } /// Publish a NIP-34 repository announcement (kind 30617) with the /// current signer. The returned task yields the published event, so /// callers can show inline progress/errors. pub fn publish_announcement( &mut self, announcement: GitRepositoryAnnouncement, cx: &mut Context, ) -> Task> { self.send(announcement.into_event_builder(), cx) } /// Sign, broadcast and store an event without awaiting the result; /// failures surface through [`BackendEvent::Error`]. The spawned task is /// owned by the backend, so it is cancelled when the backend is dropped. fn send_fire_and_forget(&mut self, builder: EventBuilder, cx: &mut Context) { let task = self.send(builder, cx); self.tasks.push(cx.spawn(async move |this, cx| { if let Err(e) = task.await { this.update(cx, |_this, cx| { cx.emit(BackendEvent::error(e.to_string())); }) .ok(); } Ok(()) })); } } /// Add the given relays, connect to them, and fetch the filters: a one-shot /// subscription (auto-closing after EOSE) plus a negentropy sync per filter /// as a second pass, so events that race with the subscription or relays /// with flaky EOSE behavior can't be missed. Relays without NEG-XX support /// just fail the sync step; the subscription already covered them. async fn connect_repo_relays_only( client: &Client, relays: Vec, filters: Vec, ) -> Result<(), Error> { if relays.is_empty() { return Ok(()); } for url in &relays { client.add_relay(url).await?; } client.connect().await; let opts = SubscribeAutoCloseOptions::default() .exit_policy(ReqExitPolicy::ExitOnEOSE) .timeout(Some(Duration::from_secs(10))); let target: HashMap<&str, Vec> = relays .iter() .map(|url| (url.as_str(), filters.clone())) .collect(); client.subscribe(target).close_on(opts).await?; for filter in filters { let sync_opts = SyncOptions::default().initial_timeout(Duration::from_secs(5)); if let Err(e) = client .sync(filter) .with(relays.iter()) .opts(sync_opts) .await { log::warn!("repo relay negentropy sync failed: {e}"); } } Ok(()) } /// Subscribe only on the bootstrap relays, auto-closing after EOSE or a /// short timeout. Use for one-shot data fetches (repo events, profiles) /// instead of persistent gossip-routed subscriptions. pub(crate) async fn subscribe_bootstrap_only( client: &Client, filters: Vec, ) -> Result<(), Error> { let opts = SubscribeAutoCloseOptions::default() .exit_policy(ReqExitPolicy::ExitOnEOSE) .timeout(Some(Duration::from_secs(10))); let target: HashMap<&str, Vec> = BOOTSTRAP_RELAYS .iter() .map(|relay| (*relay, filters.clone())) .collect(); client.subscribe(target).close_on(opts).await?; Ok(()) } /// Negentropy-sync the filter against the bootstrap relays only. pub(crate) async fn sync_bootstrap_only( client: &Client, filter: Filter, opts: SyncOptions, ) -> Result { let output = client .sync(filter) .with(BOOTSTRAP_RELAYS) .opts(opts) .await?; Ok(output.value) } /// Embed a NIP-46 session key into a bunker URI as `?master=`. fn with_master_key(uri: &str, keys: &Keys) -> String { let separator = if uri.contains('?') { '&' } else { '?' }; let nsec = keys.secret_key().to_bech32().expect("infallible"); format!("{uri}{separator}master={nsec}") } /// Split a stored bunker credential into the plain URI and the session key. /// Credentials without an embedded key (legacy) get a fresh one. fn extract_master_key(credential: &str) -> (&str, Keys) { match credential.split_once("master=") { Some((base, nsec)) => { let keys = SecretKey::parse(nsec) .map(Keys::new) .unwrap_or_else(|_| Keys::generate()); (base.trim_end_matches(['?', '&']), keys) } None => (credential, Keys::generate()), } }