From 51555b8ce4b87f1d8da43dd6dfe95327e36bf782 Mon Sep 17 00:00:00 2001 From: Tommy Volk Date: Wed, 25 Sep 2024 08:29:51 -0500 Subject: [PATCH] feat: NIP-87 federation discovery --- assets/icons/search.svg | 1 + src/nostr.rs | 59 ++++++- src/routes/bitcoin_wallet/add.rs | 294 ++++++++++++++++++++++++++++++- src/ui_components/icon.rs | 2 + 4 files changed, 346 insertions(+), 10 deletions(-) create mode 100644 assets/icons/search.svg diff --git a/assets/icons/search.svg b/assets/icons/search.svg new file mode 100644 index 0000000..49527da --- /dev/null +++ b/assets/icons/search.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/nostr.rs b/src/nostr.rs index 638c963..a8e9971 100644 --- a/src/nostr.rs +++ b/src/nostr.rs @@ -1,10 +1,16 @@ -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::fmt::Debug; +use std::str::FromStr; use std::time::Duration; +use fedimint_core::config::FederationId; +use fedimint_core::invite_code::InviteCode; use iced::Subscription; use nostr_relay_pool::RelayStatus; -use nostr_sdk::Url; +use nostr_sdk::{Alphabet, EventSource, Filter, Kind, PublicKey, SingleLetterTag, TagKind, Url}; + +const D_TAG: TagKind = TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::D)); +const U_TAG: TagKind = TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::U)); #[derive(Default, Debug, Clone, PartialEq, Eq)] pub struct NostrState { @@ -69,6 +75,55 @@ impl NostrModule { ) } + pub async fn find_federations( + &self, + ) -> Result< + BTreeMap, BTreeSet)>, + nostr_sdk::client::Error, + > { + let fedimint_recommendation_events = self + .client + .get_events_of( + vec![Filter::new() + .kind(Kind::Custom(38_000)) + .custom_tag(SingleLetterTag::lowercase(Alphabet::K), vec!["38173"]) + .custom_tag(SingleLetterTag::lowercase(Alphabet::N), vec!["mainnet"])], + EventSource::both(None), + ) + .await?; + + let mut federations = BTreeMap::new(); + + for recommendation_event in &fedimint_recommendation_events { + for d_tag in recommendation_event.get_tags_content(D_TAG) { + let Ok(federation_id) = FederationId::from_str(d_tag) else { + continue; + }; + + let (recommenders, invite_codes) = federations + .entry(federation_id) + .or_insert_with(|| (BTreeSet::new(), BTreeSet::new())); + + recommenders.insert(recommendation_event.pubkey); + + for u_tag in recommendation_event.get_tags_content(U_TAG) { + if let Ok(invite_code) = InviteCode::from_str(u_tag) { + if invite_code.federation_id() == federation_id { + invite_codes.insert(invite_code); + } + } + } + } + } + + // It's possible for a user to recommend a federation without providing any invite codes. + // If a federation has no recommendations that include any invite codes, we don't want to + // include it in the list of federations since it's not possible to join it. + federations.retain(|_, (_, invite_codes)| !invite_codes.is_empty()); + + Ok(federations) + } + /// Fetches the current state of the Nostr SDK client. /// Note: This is async because it's grabbing read locks /// on the relay `RwLock`s. No network requests are made. diff --git a/src/routes/bitcoin_wallet/add.rs b/src/routes/bitcoin_wallet/add.rs index 13a3ba8..cf1f8be 100644 --- a/src/routes/bitcoin_wallet/add.rs +++ b/src/routes/bitcoin_wallet/add.rs @@ -1,18 +1,23 @@ -use std::str::FromStr; -use std::sync::Arc; +use std::collections::BTreeSet; +use std::{collections::BTreeMap, str::FromStr}; +use fedimint_core::config::FederationId; use fedimint_core::{ config::{ClientConfig, META_FEDERATION_NAME_KEY}, invite_code::InviteCode, }; +use iced::widget::{container::Style, Container}; use iced::{ + futures::{stream::FuturesUnordered, StreamExt}, widget::{text_input, Column, Text}, - Task, + Border, Element, Shadow, Task, }; +use iced::{Length, Theme}; +use nostr_sdk::PublicKey; +use crate::util::lighten; use crate::{ app, - fedimint::Wallet, routes::{self, container, ConnectedState, Loadable}, ui_components::{icon_button, PaletteColor, SvgIcon, Toast, ToastStatus}, util::truncate_text, @@ -35,12 +40,19 @@ pub enum Message { JoinFederation(InviteCode), JoinedFederation(InviteCode), + + LoadNip87Federations, + LoadedNip87Federations(BTreeMap, BTreeSet)>), } pub struct Page { - wallet: Arc, + connected_state: ConnectedState, invite_code_input: String, parsed_invite_code_state_or: Option, + // TODO: Simplify this type and remove the clippy warning. + #[allow(clippy::type_complexity)] + nip_87_data_or: + Option, Vec)>>>, } struct ParsedInviteCodeState { @@ -51,9 +63,10 @@ struct ParsedInviteCodeState { impl Page { pub fn new(connected_state: &ConnectedState) -> Self { Self { - wallet: connected_state.wallet.clone(), + connected_state: connected_state.clone(), invite_code_input: String::new(), parsed_invite_code_state_or: None, + nip_87_data_or: None, } } @@ -102,10 +115,15 @@ impl Page { { // If the invite code has changed since the request was made, ignore the response. if &invite_code == parsed_invite_code { - *loadable_federation_config = Loadable::Loaded(config); + *loadable_federation_config = Loadable::Loaded(config.clone()); } } + self.handle_client_config_outcome_for_invite_code( + &invite_code, + &Loadable::Loaded(config), + ); + Task::none() } Message::FailedToLoadFederationConfigFromInviteCode { invite_code } => { @@ -123,11 +141,13 @@ impl Page { } } + self.handle_client_config_outcome_for_invite_code(&invite_code, &Loadable::Failed); + // TODO: Show toast instead of returning an empty task. Task::none() } Message::JoinFederation(invite_code) => { - let wallet = self.wallet.clone(); + let wallet = self.connected_state.wallet.clone(); Task::stream(async_stream::stream! { match wallet.join_federation(invite_code.clone()).await { @@ -164,9 +184,92 @@ impl Page { Task::none() } + Message::LoadNip87Federations => { + self.nip_87_data_or = Some(Loadable::Loading); + + let nostr_module = self.connected_state.nostr_module.clone(); + + Task::future(async move { + match nostr_module.find_federations().await { + Ok(federations) => { + app::Message::Routes(routes::Message::BitcoinWalletPage( + super::Message::Add(Message::LoadedNip87Federations(federations)), + )) + } + Err(_err) => app::Message::AddToast(Toast { + title: "Failed to discover federations".to_string(), + body: "Nostr NIP-87 federation discovery failed.".to_string(), + status: ToastStatus::Bad, + }), + } + }) + } + Message::LoadedNip87Federations(nip_87_data) => { + // Only set the state to loaded if the user requested the data. + // This prevents the data from being displayed if the user + // navigates away from the page and back before the data is loaded. + if matches!(self.nip_87_data_or, Some(Loadable::Loading)) { + self.nip_87_data_or = Some(Loadable::Loaded( + nip_87_data + .clone() + .into_iter() + .map(|(federation_id, (pubkeys, invite_codes))| { + ( + federation_id, + ( + pubkeys, + invite_codes + .into_iter() + .map(|invite_code| ParsedInviteCodeState { + invite_code, + loadable_federation_config: Loadable::Loading, + }) + .collect(), + ), + ) + }) + .collect(), + )); + } + + Task::stream(async_stream::stream! { + let mut futures = FuturesUnordered::new(); + + for (_, (_, invite_codes)) in nip_87_data { + if let Some(invite_code) = invite_codes.first().cloned() { + futures.push(async move { + match fedimint_api_client::download_from_invite_code(&invite_code).await { + Ok(config) => { + app::Message::Routes(routes::Message::BitcoinWalletPage( + super::Message::Add(Message::LoadedFederationConfigFromInviteCode { + invite_code: invite_code.clone(), + config, + }), + )) + } + // TODO: Include error in message and display it in the UI. + Err(_err) => { + app::Message::Routes(routes::Message::BitcoinWalletPage( + super::Message::Add(Message::FailedToLoadFederationConfigFromInviteCode { + invite_code: invite_code.clone(), + }), + )) + } + } + }); + } + } + + while let Some(result) = futures.next().await { + yield result; + } + }) + } } } + // TODO: Remove this clippy exception. + #[allow(clippy::too_many_lines)] pub fn view<'a>(&self) -> Column<'a, app::Message> { let mut container = container("Join Federation") .push( @@ -239,6 +342,161 @@ impl Page { } } + let nip_87_view: Element = match &self.nip_87_data_or { + None => icon_button( + "Find Federations", + SvgIcon::Search, + PaletteColor::Background, + ) + .on_press(app::Message::Routes(routes::Message::BitcoinWalletPage( + super::Message::Add(Message::LoadNip87Federations), + ))) + .into(), + Some(Loadable::Loading) => Text::new("Loading...").into(), + Some(Loadable::Loaded(federation_data)) => { + let mut column = Column::new().spacing(10); + + let mut federation_data_sorted_by_recommendations: Vec<_> = federation_data + .iter() + .map(|(federation_id, (pubkeys, invite_codes))| { + (federation_id, pubkeys, invite_codes) + }) + .collect(); + + federation_data_sorted_by_recommendations + .sort_by_key(|(_, pubkeys, _)| pubkeys.len()); + federation_data_sorted_by_recommendations.reverse(); + + // Filter out federations that we're already connected to. + if let Loadable::Loaded(wallet_view) = &self.connected_state.loadable_wallet_view { + let connected_federation_ids = + wallet_view.federations.keys().collect::>(); + + federation_data_sorted_by_recommendations.retain(|(federation_id, _, _)| { + !connected_federation_ids.contains(federation_id) + }); + } + + for (federation_id, pubkeys, invite_codes) in + federation_data_sorted_by_recommendations + { + let mut sub_column = Column::new() + .push(Text::new(format!("Federation ID: {federation_id}"))) + .push(Text::new(format!("{} recommendations", pubkeys.len()))); + + let mut loading_invite_codes: Vec<&ParsedInviteCodeState> = Vec::new(); + let mut loaded_invite_codes: Vec<&ParsedInviteCodeState> = Vec::new(); + let mut errored_invite_codes: Vec<&ParsedInviteCodeState> = Vec::new(); + for invite_code in invite_codes { + match &invite_code.loadable_federation_config { + Loadable::Loading => { + loading_invite_codes.push(invite_code); + } + Loadable::Loaded(_) => { + loaded_invite_codes.push(invite_code); + } + Loadable::Failed => { + errored_invite_codes.push(invite_code); + } + } + } + + let mut most_progressed_invite_code_or = None; + // The order of priority is errored, loading, loaded. + // This is important because we don't want to consider a + // federation as errored if one of its invite codes is loading, and + // we don't want to consider a federation as loading if one of its + // invite codes has successfully loaded. + if !errored_invite_codes.is_empty() { + most_progressed_invite_code_or = Some(errored_invite_codes[0]); + } else if !loading_invite_codes.is_empty() { + most_progressed_invite_code_or = Some(loading_invite_codes[0]); + } else if !loaded_invite_codes.is_empty() { + most_progressed_invite_code_or = Some(loaded_invite_codes[0]); + } + + if let Some(most_progressed_invite_code) = most_progressed_invite_code_or { + match &most_progressed_invite_code.loadable_federation_config { + Loadable::Loading => { + sub_column = sub_column.push(Text::new("Loading client config...")); + } + Loadable::Loaded(client_config) => { + sub_column = sub_column + .push(Text::new("Federation Name").size(25)) + .push(Text::new( + client_config + .meta::(META_FEDERATION_NAME_KEY) + .ok() + .flatten() + .unwrap_or_default(), + )) + .push(Text::new("Modules").size(25)) + .push(Text::new( + client_config + .modules + .values() + .map(|module| module.kind().to_string()) + .collect::>() + .join(", "), + )) + .push(Text::new("Guardians").size(25)); + for peer_url in client_config.global.api_endpoints.values() { + sub_column = sub_column.push(Text::new(format!( + "{} ({})", + peer_url.name, peer_url.url + ))); + } + + sub_column = sub_column.push( + icon_button( + "Join Federation", + SvgIcon::Groups, + PaletteColor::Primary, + ) + .on_press( + app::Message::Routes(routes::Message::BitcoinWalletPage( + super::Message::Add(Message::JoinFederation( + most_progressed_invite_code.invite_code.clone(), + )), + )), + ), + ); + } + Loadable::Failed => { + sub_column = + sub_column.push(Text::new("Failed to load client config")); + } + } + } + + column = column.push( + Container::new(sub_column) + .padding(10) + .width(Length::Fill) + .style(|theme: &Theme| -> Style { + Style { + text_color: None, + background: Some( + lighten(theme.palette().background, 0.05).into(), + ), + border: Border { + color: iced::Color::WHITE, + width: 0.0, + radius: (8.0).into(), + }, + shadow: Shadow::default(), + } + }), + ); + } + + column.into() + } + Some(Loadable::Failed) => Text::new("Failed to load NIP-87 data").into(), + }; + + container = container.push(nip_87_view); + container = container.push( icon_button("Back", SvgIcon::ArrowBack, PaletteColor::Background).on_press( app::Message::Routes(routes::Message::Navigate(super::RouteName::BitcoinWallet( @@ -249,4 +507,24 @@ impl Page { container } + + /// Handle the outcome of a client config request from a given invite code. + fn handle_client_config_outcome_for_invite_code( + &mut self, + invite_code: &InviteCode, + loadable_client_config: &Loadable, + ) { + if let Some(Loadable::Loaded(nip_87_data)) = &mut self.nip_87_data_or { + for (_, nip_87_invite_codes) in nip_87_data.values_mut() { + for nip_87_invite_code in nip_87_invite_codes { + if &nip_87_invite_code.invite_code == invite_code + && nip_87_invite_code.loadable_federation_config == Loadable::Loading + { + nip_87_invite_code.loadable_federation_config = + loadable_client_config.clone(); + } + } + } + } + } } diff --git a/src/ui_components/icon.rs b/src/ui_components/icon.rs index 09efada..7c448a5 100644 --- a/src/ui_components/icon.rs +++ b/src/ui_components/icon.rs @@ -30,6 +30,7 @@ pub enum SvgIcon { Lock, LockOpen, Save, + Search, Send, Settings, ThumbDown, @@ -68,6 +69,7 @@ impl SvgIcon { Self::Lock => icon_handle!("lock.svg"), Self::LockOpen => icon_handle!("lock_open.svg"), Self::Save => icon_handle!("save.svg"), + Self::Search => icon_handle!("search.svg"), Self::Send => icon_handle!("send.svg"), Self::Settings => icon_handle!("settings.svg"), Self::ThumbDown => icon_handle!("thumb_down.svg"),