From 8ed8309af230e15a42ffeda57a6682e027962c10 Mon Sep 17 00:00:00 2001 From: "kody.low" Date: Sun, 2 Jun 2024 15:03:17 -0700 Subject: [PATCH] feat: start nwc profiles --- Cargo.lock | 1 + fedimint-nwc/Cargo.toml | 1 + fedimint-nwc/src/main.rs | 1 + fedimint-nwc/src/nwc/conditions.rs | 117 ++++++++ fedimint-nwc/src/nwc/handlers.rs | 3 +- fedimint-nwc/src/nwc/mod.rs | 15 +- fedimint-nwc/src/nwc/profiles.rs | 443 +++++++++++++++++++++++++++++ fedimint-nwc/src/nwc/types.rs | 12 + fedimint-nwc/src/services/nostr.rs | 56 +++- fedimint-nwc/src/utils.rs | 7 + 10 files changed, 640 insertions(+), 16 deletions(-) create mode 100644 fedimint-nwc/src/nwc/conditions.rs create mode 100644 fedimint-nwc/src/nwc/types.rs create mode 100644 fedimint-nwc/src/utils.rs diff --git a/Cargo.lock b/Cargo.lock index fb866cf..7e93d05 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1494,6 +1494,7 @@ dependencies = [ "axum", "axum-macros", "bincode", + "chrono", "clap 4.5.4", "dotenv", "futures-util", diff --git a/fedimint-nwc/Cargo.toml b/fedimint-nwc/Cargo.toml index 2e1c779..5943dac 100644 --- a/fedimint-nwc/Cargo.toml +++ b/fedimint-nwc/Cargo.toml @@ -13,6 +13,7 @@ anyhow = "1.0.75" axum = { version = "0.7.1", features = ["json"] } axum-macros = "0.4.0" bincode = "1.3.3" +chrono = "0.4.38" clap = { version = "4.5.4", features = ["derive", "env"] } dotenv = "0.15.0" futures-util = "0.3.30" diff --git a/fedimint-nwc/src/main.rs b/fedimint-nwc/src/main.rs index 0db801a..853fdff 100644 --- a/fedimint-nwc/src/main.rs +++ b/fedimint-nwc/src/main.rs @@ -9,6 +9,7 @@ pub mod nwc; pub mod server; pub mod services; pub mod state; +pub mod utils; use crate::config::Cli; use crate::server::run_server; diff --git a/fedimint-nwc/src/nwc/conditions.rs b/fedimint-nwc/src/nwc/conditions.rs new file mode 100644 index 0000000..6ccf701 --- /dev/null +++ b/fedimint-nwc/src/nwc/conditions.rs @@ -0,0 +1,117 @@ +use chrono::{DateTime, Duration, NaiveDateTime, Utc}; +use lightning_invoice::Bolt11Invoice; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct SingleUseSpendingConditions { + pub payment_hash: Option, + pub amount_sats: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct TrackedPayment { + /// Time in seconds since epoch + pub time: u64, + /// Amount in sats + pub amt: u64, + /// Payment hash + pub hash: String, +} + +/// When payments for a given payment expire +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum BudgetPeriod { + /// Resets daily at midnight UTC + Day, + /// Resets every week on sunday, midnight UTC + Week, + /// Resets every month on the first, midnight UTC + Month, + /// Resets every year on the January 1st, midnight UTC + Year, + /// Payments not older than the given number of seconds are counted + Seconds(u64), +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct BudgetedSpendingConditions { + /// Amount in sats for the allotted budget period + pub budget: u64, + /// Max amount in sats for a single payment + pub single_max: Option, + /// Payment history + pub payments: Vec, + /// Time period the budget is for + pub period: BudgetPeriod, +} + +impl BudgetedSpendingConditions { + pub fn add_payment(&mut self, invoice: &Bolt11Invoice) { + let time = crate::utils::now().as_secs(); + let payment = TrackedPayment { + time, + amt: invoice.amount_milli_satoshis().unwrap_or_default() / 1_000, + hash: invoice.payment_hash().into_32().to_lower_hex_string(), + }; + + self.payments.push(payment); + } + + pub fn remove_payment(&mut self, invoice: &Bolt11Invoice) { + let hex = invoice.payment_hash().into_32().to_lower_hex_string(); + self.payments.retain(|p| p.hash != hex); + } + + fn clean_old_payments(&mut self, now: DateTime) { + let period_start = match self.period { + BudgetPeriod::Day => now.date_naive().and_hms_opt(0, 0, 0).unwrap_or_default(), + BudgetPeriod::Week => (now + - Duration::days((now.weekday().num_days_from_sunday()) as i64)) + .date_naive() + .and_hms_opt(0, 0, 0) + .unwrap_or_default(), + BudgetPeriod::Month => now + .date_naive() + .with_day(1) + .unwrap_or_default() + .and_hms_opt(0, 0, 0) + .unwrap_or_default(), + BudgetPeriod::Year => NaiveDateTime::new( + now.date_naive().with_ordinal(1).unwrap_or_default(), + chrono::NaiveTime::from_hms_opt(0, 0, 0).unwrap_or_default(), + ), + BudgetPeriod::Seconds(secs) => now + .checked_sub_signed(Duration::seconds(secs as i64)) + .unwrap_or_default() + .naive_utc(), + }; + + self.payments + .retain(|p| p.time > period_start.timestamp() as u64) + } + + pub fn sum_payments(&mut self) -> u64 { + let now = Utc::now(); + self.clean_old_payments(now); + self.payments.iter().map(|p| p.amt).sum() + } + + pub fn budget_remaining(&self) -> u64 { + let mut clone = self.clone(); + self.budget.saturating_sub(clone.sum_payments()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum SpendingConditions { + SingleUse(SingleUseSpendingConditions), + /// Require approval before sending a payment + RequireApproval, + Budget(BudgetedSpendingConditions), +} + +impl Default for SpendingConditions { + fn default() -> Self { + Self::RequireApproval + } +} diff --git a/fedimint-nwc/src/nwc/handlers.rs b/fedimint-nwc/src/nwc/handlers.rs index a8ad146..a27ad04 100644 --- a/fedimint-nwc/src/nwc/handlers.rs +++ b/fedimint-nwc/src/nwc/handlers.rs @@ -15,6 +15,7 @@ use nostr_sdk::{Event, JsonUtil}; use tokio::spawn; use tracing::info; +use super::types::METHODS; use crate::database::Database; use crate::services::{MultiMintService, NostrService}; use crate::state::AppState; @@ -284,7 +285,7 @@ async fn handle_get_info() -> Result { block_height: 0, block_hash: "000000000000000000000000000000000000000000000000000000000000000000" .to_string(), - methods: super::METHODS.iter().map(|i| i.to_string()).collect(), + methods: METHODS.iter().map(|i| i.to_string()).collect(), })), }) } diff --git a/fedimint-nwc/src/nwc/mod.rs b/fedimint-nwc/src/nwc/mod.rs index c986f02..47a4d82 100644 --- a/fedimint-nwc/src/nwc/mod.rs +++ b/fedimint-nwc/src/nwc/mod.rs @@ -1,15 +1,4 @@ -use nostr::nips::nip47::Method; - +pub mod conditions; pub mod handlers; pub mod profiles; - -pub const METHODS: [Method; 8] = [ - Method::GetInfo, - Method::MakeInvoice, - Method::GetBalance, - Method::LookupInvoice, - Method::PayInvoice, - Method::MultiPayInvoice, - Method::PayKeysend, - Method::MultiPayKeysend, -]; +pub mod types; diff --git a/fedimint-nwc/src/nwc/profiles.rs b/fedimint-nwc/src/nwc/profiles.rs index 8b13789..6c757a5 100644 --- a/fedimint-nwc/src/nwc/profiles.rs +++ b/fedimint-nwc/src/nwc/profiles.rs @@ -1 +1,444 @@ +use core::fmt; +use std::cmp::Ordering; +use std::str::FromStr; +use itertools::Itertools; +use lightning_invoice::Bolt11Invoice; +use multimint::fedimint_ln_common::bitcoin::util::bip32::ExtendedPrivKey; +use nostr::nips::nip04::encrypt; +use nostr::nips::nip47::*; +use nostr::{Event, EventBuilder, EventId, Filter, JsonUtil, Keys, Kind, Tag, Timestamp}; +use nostr_sdk::secp256k1::{Secp256k1, Signing}; +use serde::{Deserialize, Serialize}; +use tracing::error; + +use super::conditions::SpendingConditions; +use crate::services::nostr::derive_nwc_keys; +use crate::utils; + +/// Type of Nostr Wallet Connect profile +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum NwcProfileTag { + Subscription, + Gift, + General, +} + +impl Default for NwcProfileTag { + fn default() -> Self { + Self::General + } +} + +impl fmt::Display for NwcProfileTag { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Subscription => write!(f, "Subscription"), + Self::Gift => write!(f, "Gift"), + Self::General => write!(f, "General"), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub(crate) struct Profile { + pub name: String, + pub index: u32, + #[serde(skip_serializing_if = "Option::is_none")] + pub client_key: Option, + pub relay: String, + pub enabled: Option, + /// Archived profiles will not be displayed + pub archived: Option, + /// Require approval before sending a payment + #[serde(default)] + pub spending_conditions: SpendingConditions, + /// Allowed commands for this profile + pub(crate) commands: Option>, + /// index to use to derive nostr keys for child index + /// set to Option so that we keep using `index` for reserved + existing + #[serde(default)] + pub child_key_index: Option, + #[serde(default)] + pub tag: NwcProfileTag, + #[serde(skip_serializing_if = "Option::is_none")] + pub label: Option, +} + +impl Profile { + pub fn active(&self) -> bool { + match (self.enabled, self.archived) { + (Some(enabled), Some(archived)) => enabled && !archived, + (Some(enabled), None) => enabled, + (None, Some(archived)) => !archived, + (None, None) => true, + } + } + + /// Returns the available commands for this profile + pub fn available_commands(&self) -> &[Method] { + // if None this is an old profile and we should only allow pay invoice + match self.commands.as_ref() { + None => &[Method::PayInvoice], + Some(cmds) => cmds, + } + } +} + +impl PartialOrd for Profile { + fn partial_cmp(&self, other: &Self) -> Option { + self.index.partial_cmp(&other.index) + } +} + +#[derive(Clone)] +pub(crate) struct NostrWalletConnect { + /// Client key used for Nostr Wallet Connect. + /// Given to the client in the connect URI. + pub(crate) client_key: Keys, + /// Server key used for Nostr Wallet Connect. + /// Used by the nostr client to encrypt messages to the wallet. + /// Used by the server to decrypt messages from the nostr client. + pub(crate) server_key: Keys, + pub(crate) profile: Profile, +} + +impl NostrWalletConnect { + pub fn new( + context: &Secp256k1, + xprivkey: ExtendedPrivKey, + profile: Profile, + ) -> Result { + let key_derivation_index = profile.child_key_index.unwrap_or(profile.index); + + let (derived_client_key, server_key) = + derive_nwc_keys(context, xprivkey, key_derivation_index)?; + + // if the profile has a client key, we should use that instead of the derived + // one, that means that the profile was created from NWA + let client_key = match profile.client_key { + Some(client_key) => Keys::from_public_key(client_key), + None => derived_client_key, + }; + + Ok(Self { + client_key, + server_key, + profile, + }) + } + + pub fn get_nwc_uri(&self) -> anyhow::Result> { + match self.client_key.secret_key().ok() { + Some(sk) => Ok(Some(NostrWalletConnectURI::new( + self.server_key.public_key(), + self.profile.relay.parse()?, + sk.clone(), + None, + ))), + None => Ok(None), + } + } + + pub fn client_pubkey(&self) -> nostr::PublicKey { + self.client_key.public_key() + } + + pub fn server_pubkey(&self) -> nostr::PublicKey { + self.server_key.public_key() + } + + pub fn create_nwc_filter(&self, timestamp: Timestamp) -> Filter { + Filter::new() + .kinds(vec![Kind::WalletConnectRequest]) + .author(self.client_pubkey()) + .pubkey(self.server_pubkey()) + .since(timestamp) + } + + /// Create Nostr Wallet Connect Info event + pub fn create_nwc_info_event(&self) -> anyhow::Result { + let commands = self + .profile + .available_commands() + .iter() + .map(|c| c.to_string()) + .join(" "); + let info = + EventBuilder::new(Kind::WalletConnectInfo, commands, []).to_event(&self.server_key)?; + Ok(info) + } + + /// Create Nostr Wallet Auth Confirmation event + pub fn create_auth_confirmation_event( + &self, + uri_relay: Url, + secret: String, + commands: Vec, + ) -> anyhow::Result> { + // skip non-NWA profiles + if self.profile.client_key.is_none() { + return Ok(None); + } + + // if the relay is the same as the profile, we don't need to send it + let relay = if uri_relay == Url::parse(&self.profile.relay)? { + None + } else { + Some(self.profile.relay.clone()) + }; + + let json = NIP49Confirmation { + secret, + commands, + relay, + }; + let content = encrypt( + self.server_key.secret_key()?, + &self.client_pubkey(), + serde_json::to_string(&json)?, + )?; + let d_tag = Tag::Identifier(self.client_pubkey().to_hex()); + let event = EventBuilder::new(Kind::ParameterizedReplaceable(33194), content, [d_tag]) + .to_event(&self.server_key)?; + Ok(Some(event)) + } + + pub(crate) async fn pay_nwc_invoice( + &self, + node: &impl InvoiceHandler, + invoice: &Bolt11Invoice, + ) -> Result { + let label = self + .profile + .label + .clone() + .unwrap_or(self.profile.name.clone()); + match node.pay_invoice(invoice, None, vec![label]).await { + Ok(inv) => { + // preimage should be set after a successful payment + let preimage = inv.preimage.ok_or(anyhow::anyhow!("preimage not set"))?; + Ok(Response { + result_type: Method::PayInvoice, + error: None, + result: Some(ResponseResult::PayInvoice(PayInvoiceResponseResult { + preimage, + })), + }) + } + Err(e) => { + error!("failed to pay invoice: {e}"); + Err(e) + } + } + } + + async fn save_pending_nwc_invoice( + &self, + nostr_manager: &NostrManager, + event_id: EventId, + event_pk: nostr::PublicKey, + invoice: Bolt11Invoice, + identifier: Option, + ) -> anyhow::Result<()> { + nostr_manager + .save_pending_nwc_invoice( + Some(self.profile.index), + event_id, + event_pk, + invoice, + identifier, + ) + .await + } + + fn get_skipped_error_event( + &self, + event: &Event, + result_type: Method, + error_code: ErrorCode, + message: String, + ) -> anyhow::Result { + let server_key = self.server_key.secret_key()?; + let client_pubkey = self.client_key.public_key(); + let content = Response { + result_type, + error: Some(NIP47Error { + code: error_code, + message, + }), + result: None, + }; + + let encrypted = encrypt(server_key, &client_pubkey, content.as_json())?; + + let p_tag = Tag::public_key(event.pubkey); + let e_tag = Tag::event(event.id); + let response = EventBuilder::new(Kind::WalletConnectResponse, encrypted, [p_tag, e_tag]) + .to_event(&self.server_key)?; + + Ok(response) + } + + pub fn nwc_profile(&self) -> NwcProfile { + NwcProfile { + name: self.profile.name.clone(), + index: self.profile.index, + client_key: self.profile.client_key, + relay: self.profile.relay.clone(), + enabled: self.profile.enabled, + archived: self.profile.archived, + nwc_uri: match self.get_nwc_uri() { + Ok(Some(uri)) => Some(uri.to_string()), + _ => { + error!("Failed to get nwc uri"); + None + } + }, + spending_conditions: self.profile.spending_conditions.clone(), + commands: self.profile.commands.clone(), + child_key_index: self.profile.child_key_index, + tag: self.profile.tag, + label: self.profile.label.clone(), + } + } +} + +/// Struct for externally exposing a nostr wallet connect profile +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct NwcProfile { + pub name: String, + pub index: u32, + /// Public Key given in a Nostr Wallet Auth URI. + /// This will only be defined for profiles created through Nostr Wallet + /// Auth. + #[serde(skip_serializing_if = "Option::is_none")] + pub client_key: Option, + pub relay: String, + pub enabled: Option, + pub archived: Option, + /// Nostr Wallet Connect URI + /// This will only be defined for profiles created manually. + pub nwc_uri: Option, + #[serde(default)] + pub spending_conditions: SpendingConditions, + /// Allowed commands for this profile + pub commands: Option>, + #[serde(default)] + pub child_key_index: Option, + #[serde(default)] + pub tag: NwcProfileTag, + #[serde(skip_serializing_if = "Option::is_none")] + pub label: Option, +} + +impl NwcProfile { + pub(crate) fn profile(&self) -> Profile { + Profile { + name: self.name.clone(), + index: self.index, + client_key: self.client_key, + relay: self.relay.clone(), + archived: self.archived, + enabled: self.enabled, + spending_conditions: self.spending_conditions.clone(), + commands: self.commands.clone(), + child_key_index: self.child_key_index, + tag: self.tag, + label: self.label.clone(), + } + } +} + +/// An invoice received over Nostr Wallet Connect that is pending approval or +/// rejection +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct PendingNwcInvoice { + /// Index of the profile that received the invoice. + /// None if invoice is from a DM + pub index: Option, + /// The invoice that awaiting approval + pub invoice: Bolt11Invoice, + /// The nostr event id of the request + pub event_id: EventId, + /// The nostr pubkey of the request + /// If this is a DM, this is who sent us the request + pub pubkey: nostr::PublicKey, + /// `id` parameter given in the original request + /// This is normally only given for MultiPayInvoice requests + pub identifier: Option, +} + +impl PartialOrd for PendingNwcInvoice { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for PendingNwcInvoice { + fn cmp(&self, other: &Self) -> Ordering { + self.invoice.to_string().cmp(&other.invoice.to_string()) + } +} + +impl PendingNwcInvoice { + pub fn is_expired(&self) -> bool { + self.invoice.would_expire(utils::now()) + } +} + +/// Checks if it is a valid invoice +/// Return an error string if invalid +/// Otherwise returns an optional invoice that should be processed +pub(crate) async fn check_valid_nwc_invoice( + params: &PayInvoiceRequestParams, + invoice_handler: &impl InvoiceHandler, +) -> Result, String> { + let invoice = match Bolt11Invoice::from_str(¶ms.invoice) { + Ok(invoice) => invoice, + Err(_) => return Err("Invalid invoice".to_string()), + }; + + // if the invoice has expired, skip it + if invoice.would_expire(utils::now()) { + return Err("Invoice expired".to_string()); + } + + // if the invoice has no amount, we cannot pay it + if invoice.amount_milli_satoshis().is_none() { + log_warn!( + invoice_handler.logger(), + "NWC Invoice amount not set, cannot pay: {invoice}" + ); + + if params.amount.is_none() { + return Err("Invoice amount not set".to_string()); + } + + // TODO we cannot pay invoices with msat values so for now return an error + return Err("Paying 0 amount invoices is not supported yet".to_string()); + } + + if invoice_handler.skip_hodl_invoices() { + // Skip potential hodl invoices as they can cause force closes + if utils::is_hodl_invoice(&invoice) { + log_warn!( + invoice_handler.logger(), + "Received potential hodl invoice, skipping..." + ); + return Err("Paying hodl invoices disabled".to_string()); + } + } + + // if we have already paid or are attempting to pay this invoice, skip it + if invoice_handler + .lookup_payment(&invoice.payment_hash().into_32()) + .await + .map(|i| i.status) + .is_some_and(|status| matches!(status, HTLCStatus::Succeeded | HTLCStatus::InFlight)) + { + return Ok(None); + } + + Ok(Some(invoice)) +} diff --git a/fedimint-nwc/src/nwc/types.rs b/fedimint-nwc/src/nwc/types.rs new file mode 100644 index 0000000..5682773 --- /dev/null +++ b/fedimint-nwc/src/nwc/types.rs @@ -0,0 +1,12 @@ +use nostr::nips::nip47::Method; + +pub const METHODS: [Method; 8] = [ + Method::GetInfo, + Method::MakeInvoice, + Method::GetBalance, + Method::LookupInvoice, + Method::PayInvoice, + Method::MultiPayInvoice, + Method::PayKeysend, + Method::MultiPayKeysend, +]; diff --git a/fedimint-nwc/src/services/nostr.rs b/fedimint-nwc/src/services/nostr.rs index 806e300..8af505a 100644 --- a/fedimint-nwc/src/services/nostr.rs +++ b/fedimint-nwc/src/services/nostr.rs @@ -3,9 +3,11 @@ use std::io::{BufReader, Write}; use std::path::PathBuf; use anyhow::{anyhow, Context, Result}; +use multimint::fedimint_ln_common::bitcoin::util::bip32::ExtendedPrivKey; use nostr::nips::nip04; use nostr::nips::nip47::Response; -use nostr_sdk::secp256k1::SecretKey; +use nostr_sdk::bitcoin::bip32::{ChildNumber, DerivationPath}; +use nostr_sdk::secp256k1::{Secp256k1, SecretKey, Signing}; use nostr_sdk::{ Client, Event, EventBuilder, EventId, JsonUtil, Keys, Kind, RelayPoolNotification, Tag, }; @@ -13,7 +15,10 @@ use serde::{Deserialize, Serialize}; use tokio::sync::broadcast::Receiver; use tracing::info; -use crate::nwc::METHODS; +use crate::nwc::types::METHODS; + +const PROFILE_ACCOUNT_INDEX: u32 = 0; +const NWC_ACCOUNT_INDEX: u32 = 1; #[derive(Debug, Clone, Deserialize, Serialize)] pub struct NostrService { @@ -165,3 +170,50 @@ impl NostrService { && event.pubkey == self.user_keys().public_key() } } + +/// 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( + context: &Secp256k1, + xprivkey: ExtendedPrivKey, + profile_index: u32, +) -> Result<(Keys, Keys), anyhow::Error> { + let client_key = derive_nostr_key( + context, + xprivkey, + NWC_ACCOUNT_INDEX, + Some(profile_index), + Some(0), + )?; + let server_key = derive_nostr_key( + context, + xprivkey, + NWC_ACCOUNT_INDEX, + Some(profile_index), + Some(1), + )?; + + Ok((client_key, server_key)) +} + +pub fn derive_nostr_key( + context: &Secp256k1, + xprivkey: ExtendedPrivKey, + account: u32, + chain: Option, + index: Option, +) -> Result { + let chain = match chain { + Some(chain) => ChildNumber::from_hardened_idx(chain)?, + None => ChildNumber::from_normal_idx(0)?, + }; + + let index = match index { + Some(index) => ChildNumber::from_hardened_idx(index)?, + None => ChildNumber::from_normal_idx(0)?, + }; + + let path = DerivationPath::from_str(&format!("m/44'/1237'/{account}'/{chain}/{index}"))?; + let key = xprivkey.derive_priv(context, &path)?; + Ok(Keys::new(key.private_key.into())) +} diff --git a/fedimint-nwc/src/utils.rs b/fedimint-nwc/src/utils.rs new file mode 100644 index 0000000..7906599 --- /dev/null +++ b/fedimint-nwc/src/utils.rs @@ -0,0 +1,7 @@ +use chrono::Duration; + +pub fn now() -> Duration { + return std::time::SystemTime::now() + .duration_since(std::time::SystemTime::UNIX_EPOCH) + .unwrap_or_default(); +}