Skip to content

Commit

Permalink
Mint discoverability
Browse files Browse the repository at this point in the history
  • Loading branch information
benthecarman committed Mar 12, 2024
1 parent 12cbcd1 commit aa1fc06
Show file tree
Hide file tree
Showing 3 changed files with 249 additions and 1 deletion.
183 changes: 182 additions & 1 deletion mutiny-core/src/nostr/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use crate::labels::Contact;
use crate::logging::MutinyLogger;
use crate::nostr::nip49::{NIP49BudgetPeriod, NIP49URI};
use crate::nostr::nwc::{
Expand All @@ -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;
Expand All @@ -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};
Expand Down Expand Up @@ -102,6 +106,25 @@ pub struct NostrManager<S: MutinyStorage> {
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<InviteCode>,
/// 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<Metadata>,
/// Contacts that recommend this fedimint
pub recommendations: Vec<Contact>,
}

impl<S: MutinyStorage> NostrManager<S> {
/// Connect to the nostr relays
pub async fn connect(&self) -> Result<(), MutinyError> {
Expand Down Expand Up @@ -1172,6 +1195,164 @@ impl<S: MutinyStorage> NostrManager<S> {
Ok(event_id)
}

/// Queries our relays for federation announcements
pub async fn discover_federations(&self) -> Result<Vec<NostrDiscoveredFedimint>, MutinyError> {
// get contacts by npub
let mut npubs: HashMap<nostr::PublicKey, Contact> = 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<NostrDiscoveredFedimint> = 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<InviteCode> = 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<InviteCode> = 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<C: Signing>(
Expand Down
59 changes: 59 additions & 0 deletions mutiny-core/src/nostr/primal.rs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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<Vec<TrustedUser>, MutinyError> {
// fixme doesn't work with mutiny caching service
let body = json!(["trusted_users", {"limit": limit }]);
let data: Vec<Value> = 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<TrustedUser> =
serde_json::from_str(&event.content).map_err(|_| MutinyError::NostrError)?;

// parse kind0 events
let metadata: HashMap<nostr::PublicKey, Metadata> = 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<Metadata>,
}
8 changes: 8 additions & 0 deletions mutiny-wasm/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<JsValue /* Vec<NostrDiscoveredFedimint> */, MutinyJsError> {
let federations = self.inner.nostr.discover_federations().await?;
Ok(JsValue::from_serde(&federations)?)
}

pub fn get_address_labels(
&self,
) -> Result<JsValue /* Map<Address, Vec<String>> */, MutinyJsError> {
Expand Down

0 comments on commit aa1fc06

Please sign in to comment.