From aa1fc06380561275b2b574bc18f33b22a4f1c5a4 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Fri, 8 Mar 2024 23:17:22 +0000 Subject: [PATCH] Mint discoverability --- mutiny-core/src/nostr/mod.rs | 183 +++++++++++++++++++++++++++++++- mutiny-core/src/nostr/primal.rs | 59 ++++++++++ mutiny-wasm/src/lib.rs | 8 ++ 3 files changed, 249 insertions(+), 1 deletion(-) diff --git a/mutiny-core/src/nostr/mod.rs b/mutiny-core/src/nostr/mod.rs index 7552bb3de..a028b3376 100644 --- a/mutiny-core/src/nostr/mod.rs +++ b/mutiny-core/src/nostr/mod.rs @@ -1,3 +1,4 @@ +use crate::labels::Contact; use crate::logging::MutinyLogger; use crate::nostr::nip49::{NIP49BudgetPeriod, NIP49URI}; use crate::nostr::nwc::{ @@ -14,6 +15,8 @@ use bitcoin::bip32::{ChildNumber, DerivationPath, ExtendedPrivKey}; use bitcoin::hashes::{sha256, Hash}; use bitcoin::secp256k1::{Secp256k1, Signing}; use bitcoin::{hashes::hex::FromHex, secp256k1::ThirtyTwoByteHash}; +use fedimint_core::api::InviteCode; +use fedimint_core::config::FederationId; use futures::{pin_mut, select, FutureExt}; use futures_util::lock::Mutex; use lightning::util::logger::Logger; @@ -25,7 +28,8 @@ use nostr::nips::nip04::{decrypt, encrypt}; use nostr::nips::nip47::*; use nostr::{Event, EventBuilder, EventId, Filter, JsonUtil, Keys, Kind, Metadata, Tag, Timestamp}; use nostr_sdk::{Client, NostrSigner, RelayPoolNotification}; -use std::collections::HashSet; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; use std::sync::{atomic::Ordering, Arc, RwLock}; use std::time::Duration; use std::{str::FromStr, sync::atomic::AtomicBool}; @@ -102,6 +106,25 @@ pub struct NostrManager { pub primal_client: PrimalClient, } +/// A fedimint we discovered on nostr +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NostrDiscoveredFedimint { + /// Invite Code to join the federation + pub invite_codes: Vec, + /// The federation id + pub id: FederationId, + /// Pubkey of the nostr event + pub pubkey: nostr::PublicKey, + /// Event id of the nostr event + pub event_id: EventId, + /// Date this fedimint was announced on nostr + pub created_at: u64, + /// Metadata about the fedimint + pub metadata: Option, + /// Contacts that recommend this fedimint + pub recommendations: Vec, +} + impl NostrManager { /// Connect to the nostr relays pub async fn connect(&self) -> Result<(), MutinyError> { @@ -1172,6 +1195,164 @@ impl NostrManager { Ok(event_id) } + /// Queries our relays for federation announcements + pub async fn discover_federations(&self) -> Result, MutinyError> { + // get contacts by npub + let mut npubs: HashMap = self + .storage + .get_contacts()? + .into_iter() + .filter_map(|(_, c)| c.npub.map(|npub| (npub, c))) + .collect(); + + const NUM_TRUSTED_USERS: u32 = 500; + + // our contacts might not have recommendation events, so pull in trusted users as well + match self + .primal_client + .get_trusted_users(NUM_TRUSTED_USERS) + .await + { + Ok(trusted) => { + for user in trusted { + // skip if we already have this contact + if npubs.contains_key(&user.pubkey) { + continue; + } + // create a dummy contact from the metadata if available + let dummy_contact = match user.metadata { + Some(metadata) => Contact::create_from_metadata(user.pubkey, metadata), + None => Contact { + npub: Some(user.pubkey), + ..Default::default() + }, + }; + npubs.insert(user.pubkey, dummy_contact); + } + } + Err(e) => { + // if we fail to get trusted users, log the error and continue + // we don't want to fail the entire function because of this + // we'll just have less recommendations + log_error!(self.logger, "Failed to get trusted users: {e}"); + } + } + + // filter for finding mint announcements + let mints = Filter::new().kind(Kind::from(38173)); + // filter for finding federation recommendations from trusted people + let trusted_recommendations = Filter::new() + .kind(Kind::from(18173)) + .authors(npubs.keys().copied()); + // filter for finding federation recommendations from random people + let recommendations = Filter::new() + .kind(Kind::from(18173)) + .limit(NUM_TRUSTED_USERS as usize); + // fetch events + let events = self + .client + .get_events_of( + vec![mints, trusted_recommendations, recommendations], + Some(Duration::from_secs(5)), + ) + .await?; + + let mut mints: Vec = events + .iter() + .filter_map(|event| { + // only process federation announcements + if event.kind != Kind::from(38173) { + return None; + } + + let federation_id = event.tags.iter().find_map(|tag| { + if let Tag::Identifier(id) = tag { + FederationId::from_str(id).ok() + } else { + None + } + })?; + + let invite_codes: Vec = event + .tags + .iter() + .filter_map(|tag| { + if let Tag::AbsoluteURL(code) = tag { + InviteCode::from_str(&code.to_string()) + .ok() + // remove any invite codes that point to different federation + .filter(|c| c.federation_id() == federation_id) + } else { + None + } + }) + .collect(); + + // if we have no invite codes left, skip + if invite_codes.is_empty() { + None + } else { + // try to parse the metadata if available, it's okay if it fails + // todo could lookup kind 0 of the federation to get the metadata as well + let metadata = serde_json::from_str(&event.content).ok(); + Some(NostrDiscoveredFedimint { + invite_codes, + id: federation_id, + pubkey: event.pubkey, + event_id: event.id, + created_at: event.created_at.as_u64(), + metadata, + recommendations: vec![], + }) + } + }) + .collect(); + + // add on contact recommendations to mints + for event in events { + // only process federation recommendations + if event.kind != Kind::from(18173) { + continue; + } + + let contact = match npubs.get(&event.pubkey) { + Some(contact) => contact.clone(), + None => continue, + }; + + let recommendations: Vec = event + .tags + .iter() + .filter_map(|tag| { + let vec = tag.as_vec(); + // if there's 3 elements, make sure the identifier is for a fedimint + // if there's 2 elements, just try to parse the invite code + if (vec.len() == 3 && vec[0] == "u" && vec[2] == "fedimint") + || (vec.len() == 2 && vec[0] == "u") + { + InviteCode::from_str(&vec[1]).ok() + } else { + None + } + }) + .collect(); + + for invite_code in recommendations { + if let Some(mint) = mints + .iter_mut() + .find(|m| m.invite_codes.contains(&invite_code)) + { + mint.recommendations.push(contact.clone()); + } + } + } + + // sort by most recommended + mints.sort_by(|a, b| b.recommendations.len().cmp(&a.recommendations.len())); + + Ok(mints) + } + /// Derives the client and server keys for Nostr Wallet Connect given a profile index /// The left key is the client key and the right key is the server key pub(crate) fn derive_nwc_keys( diff --git a/mutiny-core/src/nostr/primal.rs b/mutiny-core/src/nostr/primal.rs index 4d87cb0b8..6add5c3ca 100644 --- a/mutiny-core/src/nostr/primal.rs +++ b/mutiny-core/src/nostr/primal.rs @@ -1,6 +1,7 @@ use crate::error::MutinyError; use crate::utils::parse_profile_metadata; use nostr::{Event, Kind, Metadata}; +use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::collections::HashMap; @@ -110,4 +111,62 @@ impl PrimalClient { Ok(messages) } + + /// Returns a list of trusted users from primal with their trust rating + pub async fn get_trusted_users(&self, limit: u32) -> Result, MutinyError> { + // fixme doesn't work with mutiny caching service + let body = json!(["trusted_users", {"limit": limit }]); + let data: Vec = self.primal_request(body).await?; + + if let Some(json) = data.first().cloned() { + let event: PrimalEvent = + serde_json::from_value(json).map_err(|_| MutinyError::NostrError)?; + + let mut trusted_users: Vec = + serde_json::from_str(&event.content).map_err(|_| MutinyError::NostrError)?; + + // parse kind0 events + let metadata: HashMap = data + .into_iter() + .filter_map(|d| { + Event::from_value(d.clone()) + .ok() + .filter(|e| e.kind == Kind::Metadata) + .and_then(|e| { + serde_json::from_str(&e.content) + .ok() + .map(|m: Metadata| (e.pubkey, m)) + }) + }) + .collect(); + + // add metadata to trusted users + for user in trusted_users.iter_mut() { + if let Some(meta) = metadata.get(&user.pubkey) { + user.metadata = Some(meta.clone()); + } + } + + return Ok(trusted_users); + }; + + Err(MutinyError::NostrError) + } +} + +/// Primal will return nostr "events" which are just kind numbers +/// and a string of content. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PrimalEvent { + pub kind: Kind, + pub content: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TrustedUser { + #[serde(rename = "pk")] + pub pubkey: nostr::PublicKey, + #[serde(rename = "tr")] + pub trust_rating: f64, + pub metadata: Option, } diff --git a/mutiny-wasm/src/lib.rs b/mutiny-wasm/src/lib.rs index 4f4bb1de0..c4add3a4e 100644 --- a/mutiny-wasm/src/lib.rs +++ b/mutiny-wasm/src/lib.rs @@ -1175,6 +1175,14 @@ impl MutinyWallet { Ok(self.inner.recover_federation_backups().await?) } + /// Queries our relays for federation announcements + pub async fn discover_federations( + &self, + ) -> Result */, MutinyJsError> { + let federations = self.inner.nostr.discover_federations().await?; + Ok(JsValue::from_serde(&federations)?) + } + pub fn get_address_labels( &self, ) -> Result> */, MutinyJsError> {