Skip to content
This repository was archived by the owner on Feb 3, 2025. It is now read-only.

Mint discoverability #1060

Merged
merged 2 commits into from
Mar 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
250 changes: 247 additions & 3 deletions 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 @@ -23,11 +26,12 @@ use lnurl::lnurl::LnUrl;
use nostr::nips::nip47::*;
use nostr::{
nips::nip04::{decrypt, encrypt},
SecretKey,
Alphabet, Event, EventBuilder, EventId, Filter, JsonUtil, Keys, Kind, Metadata, SecretKey,
SingleLetterTag, Tag, TagKind, Timestamp,
};
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 All @@ -45,6 +49,9 @@ pub(crate) const HERMES_CHAIN_INDEX: u32 = 0;

const USER_NWC_PROFILE_START_INDEX: u32 = 1000;

/// The number of trusted users we query for mint recommendations
const NUM_TRUSTED_USERS: u32 = 1_000;

const NWC_STORAGE_KEY: &str = "nwc_profiles";

const DEFAULT_RELAY: &str = "wss://relay.mutinywallet.com";
Expand Down Expand Up @@ -111,6 +118,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: Option<nostr::PublicKey>,
/// Event id of the nostr event
pub event_id: Option<EventId>,
/// Date this fedimint was announced on nostr
pub created_at: Option<u64>,
/// Metadata about the fedimint
pub metadata: Option<Metadata>,
/// Contacts that recommend this fedimint
pub recommendations: HashSet<Contact>,
}

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

/// Creates a recommendation event for a federation
pub async fn recommend_federation(
&self,
invite_code: &InviteCode,
review: Option<&str>,
) -> Result<EventId, MutinyError> {
let kind = Kind::from(38000);

// properly tag the event as a federation with the federation id
let d_tag = Tag::Identifier(invite_code.federation_id().to_string());
let k_tag = Tag::Generic(
TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::K)),
vec!["38173".to_string()],
);

// tag the federation invite code
let invite_code_tag = Tag::Generic(
TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::U)),
vec![invite_code.to_string()],
);

// todo tag the federation announcement event, to do so we need to have the pubkey of the federation
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this isn't really possible?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just need to save the pubkey of it when we add it from recommendations

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a little unsure how this is supposed to work and link together. Is it just by the d_tag being the same across reviews? Does the federation invite code even need to be here? Should we pull down the other federation members invite code's to put in the vec?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it just by the d_tag being the same across reviews?

Yeah the d tag is the federation id

Does the federation invite code even need to be here?

Yeah, in the case the federation has not announced itself

Should we pull down the other federation members invite code's to put in the vec?

We could... I figured since it is a recommendation, they recommend the code they are using, maybe that is the most reliable member in the federation.


let builder = EventBuilder::new(
kind,
review.unwrap_or_default(),
[d_tag, k_tag, invite_code_tag],
);

// send the event
Ok(self.client.send_event_builder(builder).await?)
}

/// 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();

// 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(38000))
.custom_tag(SingleLetterTag::lowercase(Alphabet::K), ["38173"])
.authors(npubs.keys().copied());
// filter for finding federation recommendations from random people
let recommendations = Filter::new()
.kind(Kind::from(38000))
.custom_tag(SingleLetterTag::lowercase(Alphabet::K), ["38173"])
.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: Some(event.pubkey),
event_id: Some(event.id),
created_at: Some(event.created_at.as_u64()),
metadata,
recommendations: HashSet::new(),
})
}
})
.collect();

// add on contact recommendations to mints
for event in events {
// only process federation recommendations
if event.kind != Kind::from(38000)
&& event.tags.iter().any(|tag| {
tag.kind() == TagKind::Custom("k".to_string())
&& tag.as_vec().get(1).is_some_and(|x| x == "38173")
})
{
continue;
}

// if we don't have the contact, skip
let contact = match npubs.get(&event.pubkey) {
Some(contact) => contact.clone(),
None => continue,
};

let invite_codes = event
.tags
.iter()
.filter_map(|tag| {
// try to parse the invite code
let vec = tag.as_vec();
if vec.len() == 2 && vec[0] == "u" {
InviteCode::from_str(&vec[1]).ok()
} else {
None
}
})
.collect::<Vec<_>>();

// group invite codes by federation id so we don't duplicate mints
let mut by_federation: HashMap<FederationId, Vec<InviteCode>> = HashMap::new();
for invite_code in invite_codes {
let id = invite_code.federation_id();
by_federation.entry(id).or_default().push(invite_code);
}

// todo read federation id recommendations too

for (id, invite_codes) in by_federation {
match mints.iter_mut().find(|m| m.id == id) {
Some(mint) => {
mint.recommendations.insert(contact.clone());
}
None => {
// if we don't have the mint announcement
// Add to list with the contact as the recommendation
let mut recommendations = HashSet::new();
recommendations.insert(contact.clone());
let mint = NostrDiscoveredFedimint {
invite_codes,
id,
pubkey: None,
event_id: None,
created_at: None,
metadata: None,
recommendations,
};
mints.push(mint);
}
}
}
}

// 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
58 changes: 58 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 @@ -122,6 +123,63 @@ 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> {
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>,
}

#[cfg(test)]
Expand Down
24 changes: 24 additions & 0 deletions mutiny-wasm/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1218,6 +1218,30 @@ impl MutinyWallet {
Ok(self.inner.recover_federation_backups().await?)
}

/// Creates a recommendation event for a federation
pub async fn recommend_federation(
&self,
invite_code: String,
review: Option<String>,
) -> Result<String, MutinyJsError> {
let invite_code =
InviteCode::from_str(&invite_code).map_err(|_| MutinyJsError::InvalidArgumentsError)?;
let event_id = self
.inner
.nostr
.recommend_federation(&invite_code, review.as_deref())
.await?;
Ok(event_id.to_hex())
}

/// 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