From ae6c10780931dcecc3d9546d9ab16f2bea4782b4 Mon Sep 17 00:00:00 2001 From: thesimplekid Date: Sun, 6 Jul 2025 21:20:25 +0100 Subject: [PATCH] feat: bolt12 --- Cargo.toml | 1 + crates/cashu/Cargo.toml | 1 + crates/cashu/src/amount.rs | 46 ++ crates/cashu/src/nuts/auth/nut21.rs | 28 +- crates/cashu/src/nuts/auth/nut22.rs | 4 +- crates/cashu/src/nuts/mod.rs | 2 + crates/cashu/src/nuts/nut00/mod.rs | 5 +- crates/cashu/src/nuts/nut04.rs | 10 + crates/cashu/src/nuts/nut05.rs | 19 +- crates/cashu/src/nuts/nut17/mod.rs | 34 + crates/cashu/src/nuts/nut19.rs | 6 + crates/cashu/src/nuts/nut23.rs | 6 - crates/cashu/src/nuts/nut24.rs | 104 +++ crates/cdk-axum/src/bolt12_router.rs | 213 +++++ crates/cdk-axum/src/lib.rs | 40 +- crates/cdk-axum/src/router_handlers.rs | 16 +- crates/cdk-cli/Cargo.toml | 4 + crates/cdk-cli/src/bip353.rs | 132 +++ crates/cdk-cli/src/main.rs | 2 + crates/cdk-cli/src/sub_commands/melt.rs | 422 ++++++---- crates/cdk-cli/src/sub_commands/mint.rs | 117 ++- crates/cdk-cln/src/error.rs | 6 + crates/cdk-cln/src/lib.rs | 765 +++++++++++++----- crates/cdk-common/Cargo.toml | 1 + crates/cdk-common/src/database/mint/mod.rs | 39 +- crates/cdk-common/src/database/mod.rs | 3 + crates/cdk-common/src/error.rs | 18 + crates/cdk-common/src/lib.rs | 2 + crates/cdk-common/src/melt.rs | 26 + crates/cdk-common/src/mint.rs | 311 ++++++- crates/cdk-common/src/payment.rs | 243 +++++- crates/cdk-common/src/subscription.rs | 3 + crates/cdk-common/src/wallet.rs | 69 +- crates/cdk-common/src/ws.rs | 3 + crates/cdk-fake-wallet/Cargo.toml | 3 +- crates/cdk-fake-wallet/src/error.rs | 2 +- crates/cdk-fake-wallet/src/lib.rs | 356 +++++--- .../src/init_pure_tests.rs | 60 +- crates/cdk-integration-tests/src/lib.rs | 45 +- crates/cdk-integration-tests/tests/bolt12.rs | 332 ++++++++ .../cdk-integration-tests/tests/fake_auth.rs | 6 +- .../tests/happy_path_mint_wallet.rs | 12 +- .../tests/integration_tests_pure.rs | 6 +- crates/cdk-integration-tests/tests/regtest.rs | 4 +- .../cdk-integration-tests/tests/test_fees.rs | 4 + crates/cdk-lnbits/src/error.rs | 3 + crates/cdk-lnbits/src/lib.rs | 321 +++++--- crates/cdk-lnd/src/lib.rs | 530 ++++++------ crates/cdk-mint-rpc/Cargo.toml | 1 + crates/cdk-mint-rpc/src/proto/server.rs | 51 +- crates/cdk-mintd/src/main.rs | 89 +- crates/cdk-payment-processor/Cargo.toml | 5 +- crates/cdk-payment-processor/src/error.rs | 67 +- crates/cdk-payment-processor/src/lib.rs | 1 + .../cdk-payment-processor/src/proto/client.rs | 162 ++-- crates/cdk-payment-processor/src/proto/mod.rs | 292 ++++--- .../src/proto/payment_processor.proto | 133 ++- .../cdk-payment-processor/src/proto/server.rs | 214 +++-- crates/cdk-sqlite/src/mint/error.rs | 3 + crates/cdk-sqlite/src/mint/memory.rs | 2 +- crates/cdk-sqlite/src/mint/migrations.rs | 1 + .../mint/migrations/20250706101057_bolt12.sql | 81 ++ crates/cdk-sqlite/src/mint/mod.rs | 758 +++++++++++------ crates/cdk-sqlite/src/wallet/migrations.rs | 1 + .../migrations/20250707093445_bolt12.sql | 58 ++ crates/cdk-sqlite/src/wallet/mod.rs | 40 +- crates/cdk/Cargo.toml | 1 + crates/cdk/src/mint/builder.rs | 46 +- crates/cdk/src/mint/issue/issue_nut04.rs | 301 ------- crates/cdk/src/mint/issue/mod.rs | 600 +++++++++++++- crates/cdk/src/mint/ln.rs | 49 +- crates/cdk/src/mint/melt.rs | 278 +++++-- crates/cdk/src/mint/mod.rs | 59 +- crates/cdk/src/mint/start_up_check.rs | 8 +- .../src/mint/subscription/on_subscription.rs | 6 + .../wallet/{mint.rs => issue/issue_bolt11.rs} | 35 +- crates/cdk/src/wallet/issue/issue_bolt12.rs | 258 ++++++ crates/cdk/src/wallet/issue/mod.rs | 2 + .../wallet/{melt.rs => melt/melt_bolt11.rs} | 2 +- crates/cdk/src/wallet/melt/melt_bolt12.rs | 89 ++ crates/cdk/src/wallet/melt/mod.rs | 2 + .../src/wallet/mint_connector/http_client.rs | 102 ++- crates/cdk/src/wallet/mint_connector/mod.rs | 26 + crates/cdk/src/wallet/mod.rs | 2 +- misc/itests.sh | 7 + misc/mintd_payment_processor.sh | 14 + 86 files changed, 6297 insertions(+), 1934 deletions(-) create mode 100644 crates/cashu/src/nuts/nut24.rs create mode 100644 crates/cdk-axum/src/bolt12_router.rs create mode 100644 crates/cdk-cli/src/bip353.rs create mode 100644 crates/cdk-common/src/melt.rs create mode 100644 crates/cdk-integration-tests/tests/bolt12.rs create mode 100644 crates/cdk-sqlite/src/mint/migrations/20250706101057_bolt12.sql create mode 100644 crates/cdk-sqlite/src/wallet/migrations/20250707093445_bolt12.sql delete mode 100644 crates/cdk/src/mint/issue/issue_nut04.rs rename crates/cdk/src/wallet/{mint.rs => issue/issue_bolt11.rs} (92%) create mode 100644 crates/cdk/src/wallet/issue/issue_bolt12.rs create mode 100644 crates/cdk/src/wallet/issue/mod.rs rename crates/cdk/src/wallet/{melt.rs => melt/melt_bolt11.rs} (99%) create mode 100644 crates/cdk/src/wallet/melt/melt_bolt12.rs create mode 100644 crates/cdk/src/wallet/melt/mod.rs diff --git a/Cargo.toml b/Cargo.toml index 3aaab2b8..02418387 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -61,6 +61,7 @@ ciborium = { version = "0.2.2", default-features = false, features = ["std"] } cbor-diag = "0.1.12" futures = { version = "0.3.28", default-features = false, features = ["async-await"] } lightning-invoice = { version = "0.33.0", features = ["serde", "std"] } +lightning = { version = "0.1.2", default-features = false, features = ["std"]} serde = { version = "1", features = ["derive"] } serde_json = "1" thiserror = { version = "2" } diff --git a/crates/cashu/Cargo.toml b/crates/cashu/Cargo.toml index afc4e9f1..efbc975e 100644 --- a/crates/cashu/Cargo.toml +++ b/crates/cashu/Cargo.toml @@ -26,6 +26,7 @@ ciborium.workspace = true once_cell.workspace = true serde.workspace = true lightning-invoice.workspace = true +lightning.workspace = true thiserror.workspace = true tracing.workspace = true url.workspace = true diff --git a/crates/cashu/src/amount.rs b/crates/cashu/src/amount.rs index 9cf893a0..33bf82bf 100644 --- a/crates/cashu/src/amount.rs +++ b/crates/cashu/src/amount.rs @@ -6,6 +6,7 @@ use std::cmp::Ordering; use std::fmt; use std::str::FromStr; +use lightning::offers::offer::Offer; use serde::{Deserialize, Serialize}; use thiserror::Error; @@ -26,6 +27,12 @@ pub enum Error { /// Invalid amount #[error("Invalid Amount: {0}")] InvalidAmount(String), + /// Amount undefined + #[error("Amount undefined")] + AmountUndefined, + /// Utf8 parse error + #[error(transparent)] + Utf8ParseError(#[from] std::string::FromUtf8Error), } /// Amount can be any unit @@ -181,6 +188,24 @@ impl Amount { ) -> Result { to_unit(self.0, current_unit, target_unit) } + + /// Convert to i64 + pub fn to_i64(self) -> Option { + if self.0 <= i64::MAX as u64 { + Some(self.0 as i64) + } else { + None + } + } + + /// Create from i64, returning None if negative + pub fn from_i64(value: i64) -> Option { + if value >= 0 { + Some(Amount(value as u64)) + } else { + None + } + } } impl Default for Amount { @@ -273,6 +298,27 @@ impl std::ops::Div for Amount { } } +/// Convert offer to amount in unit +pub fn amount_for_offer(offer: &Offer, unit: &CurrencyUnit) -> Result { + let offer_amount = offer.amount().ok_or(Error::AmountUndefined)?; + + let (amount, currency) = match offer_amount { + lightning::offers::offer::Amount::Bitcoin { amount_msats } => { + (amount_msats, CurrencyUnit::Msat) + } + lightning::offers::offer::Amount::Currency { + iso4217_code, + amount, + } => ( + amount, + CurrencyUnit::from_str(&String::from_utf8(iso4217_code.to_vec())?) + .map_err(|_| Error::CannotConvertUnits)?, + ), + }; + + to_unit(amount, ¤cy, unit).map_err(|_err| Error::CannotConvertUnits) +} + /// Kinds of targeting that are supported #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Default, Serialize, Deserialize)] pub enum SplitTarget { diff --git a/crates/cashu/src/nuts/auth/nut21.rs b/crates/cashu/src/nuts/auth/nut21.rs index f99a02fc..6392bd4a 100644 --- a/crates/cashu/src/nuts/auth/nut21.rs +++ b/crates/cashu/src/nuts/auth/nut21.rs @@ -149,6 +149,18 @@ pub enum RoutePath { /// Mint Blind Auth #[serde(rename = "/v1/auth/blind/mint")] MintBlindAuth, + /// Bolt12 Mint Quote + #[serde(rename = "/v1/mint/quote/bolt12")] + MintQuoteBolt12, + /// Bolt12 Mint + #[serde(rename = "/v1/mint/bolt12")] + MintBolt12, + /// Bolt12 Melt Quote + #[serde(rename = "/v1/melt/quote/bolt12")] + MeltQuoteBolt12, + /// Bolt12 Quote + #[serde(rename = "/v1/melt/bolt12")] + MeltBolt12, } /// Returns [`RoutePath`]s that match regex @@ -195,6 +207,8 @@ mod tests { assert!(paths.contains(&RoutePath::Checkstate)); assert!(paths.contains(&RoutePath::Restore)); assert!(paths.contains(&RoutePath::MintBlindAuth)); + assert!(paths.contains(&RoutePath::MintQuoteBolt12)); + assert!(paths.contains(&RoutePath::MintBolt12)); } #[test] @@ -203,13 +217,17 @@ mod tests { let paths = matching_route_paths("^/v1/mint/.*").unwrap(); // Should match only mint paths - assert_eq!(paths.len(), 2); + assert_eq!(paths.len(), 4); assert!(paths.contains(&RoutePath::MintQuoteBolt11)); assert!(paths.contains(&RoutePath::MintBolt11)); + assert!(paths.contains(&RoutePath::MintQuoteBolt12)); + assert!(paths.contains(&RoutePath::MintBolt12)); // Should not match other paths assert!(!paths.contains(&RoutePath::MeltQuoteBolt11)); assert!(!paths.contains(&RoutePath::MeltBolt11)); + assert!(!paths.contains(&RoutePath::MeltQuoteBolt12)); + assert!(!paths.contains(&RoutePath::MeltBolt12)); assert!(!paths.contains(&RoutePath::Swap)); } @@ -219,9 +237,11 @@ mod tests { let paths = matching_route_paths(".*/quote/.*").unwrap(); // Should match only quote paths - assert_eq!(paths.len(), 2); + assert_eq!(paths.len(), 4); assert!(paths.contains(&RoutePath::MintQuoteBolt11)); assert!(paths.contains(&RoutePath::MeltQuoteBolt11)); + assert!(paths.contains(&RoutePath::MintQuoteBolt12)); + assert!(paths.contains(&RoutePath::MeltQuoteBolt12)); // Should not match non-quote paths assert!(!paths.contains(&RoutePath::MintBolt11)); @@ -336,12 +356,14 @@ mod tests { "https://example.com/.well-known/openid-configuration" ); assert_eq!(settings.client_id, "client123"); - assert_eq!(settings.protected_endpoints.len(), 3); // 2 mint paths + 1 swap path + assert_eq!(settings.protected_endpoints.len(), 5); // 3 mint paths + 1 swap path let expected_protected: HashSet = HashSet::from_iter(vec![ ProtectedEndpoint::new(Method::Post, RoutePath::Swap), ProtectedEndpoint::new(Method::Get, RoutePath::MintBolt11), ProtectedEndpoint::new(Method::Get, RoutePath::MintQuoteBolt11), + ProtectedEndpoint::new(Method::Get, RoutePath::MintQuoteBolt12), + ProtectedEndpoint::new(Method::Get, RoutePath::MintBolt12), ]); let deserlized_protected = settings.protected_endpoints.into_iter().collect(); diff --git a/crates/cashu/src/nuts/auth/nut22.rs b/crates/cashu/src/nuts/auth/nut22.rs index b92cb873..81990ea3 100644 --- a/crates/cashu/src/nuts/auth/nut22.rs +++ b/crates/cashu/src/nuts/auth/nut22.rs @@ -330,12 +330,14 @@ mod tests { let settings: Settings = serde_json::from_str(json).unwrap(); assert_eq!(settings.bat_max_mint, 5); - assert_eq!(settings.protected_endpoints.len(), 3); // 2 mint paths + 1 swap path + assert_eq!(settings.protected_endpoints.len(), 5); // 4 mint paths + 1 swap path let expected_protected: HashSet = HashSet::from_iter(vec![ ProtectedEndpoint::new(Method::Post, RoutePath::Swap), ProtectedEndpoint::new(Method::Get, RoutePath::MintBolt11), ProtectedEndpoint::new(Method::Get, RoutePath::MintQuoteBolt11), + ProtectedEndpoint::new(Method::Get, RoutePath::MintQuoteBolt12), + ProtectedEndpoint::new(Method::Get, RoutePath::MintBolt12), ]); let deserialized_protected = settings.protected_endpoints.into_iter().collect(); diff --git a/crates/cashu/src/nuts/mod.rs b/crates/cashu/src/nuts/mod.rs index d9e31218..8e8331f9 100644 --- a/crates/cashu/src/nuts/mod.rs +++ b/crates/cashu/src/nuts/mod.rs @@ -24,6 +24,7 @@ pub mod nut18; pub mod nut19; pub mod nut20; pub mod nut23; +pub mod nut24; #[cfg(feature = "auth")] mod auth; @@ -67,3 +68,4 @@ pub use nut23::{ MeltOptions, MeltQuoteBolt11Request, MeltQuoteBolt11Response, MintQuoteBolt11Request, MintQuoteBolt11Response, QuoteState as MintQuoteState, }; +pub use nut24::{MeltQuoteBolt12Request, MintQuoteBolt12Request, MintQuoteBolt12Response}; diff --git a/crates/cashu/src/nuts/nut00/mod.rs b/crates/cashu/src/nuts/nut00/mod.rs index 85f33a97..3996800f 100644 --- a/crates/cashu/src/nuts/nut00/mod.rs +++ b/crates/cashu/src/nuts/nut00/mod.rs @@ -641,13 +641,14 @@ impl<'de> Deserialize<'de> for CurrencyUnit { } /// Payment Method -#[non_exhaustive] #[derive(Debug, Clone, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))] pub enum PaymentMethod { /// Bolt11 payment type #[default] Bolt11, + /// Bolt12 + Bolt12, /// Custom Custom(String), } @@ -657,6 +658,7 @@ impl FromStr for PaymentMethod { fn from_str(value: &str) -> Result { match value.to_lowercase().as_str() { "bolt11" => Ok(Self::Bolt11), + "bolt12" => Ok(Self::Bolt12), c => Ok(Self::Custom(c.to_string())), } } @@ -666,6 +668,7 @@ impl fmt::Display for PaymentMethod { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { PaymentMethod::Bolt11 => write!(f, "bolt11"), + PaymentMethod::Bolt12 => write!(f, "bolt12"), PaymentMethod::Custom(p) => write!(f, "{p}"), } } diff --git a/crates/cashu/src/nuts/nut04.rs b/crates/cashu/src/nuts/nut04.rs index 68fbcc97..475c590c 100644 --- a/crates/cashu/src/nuts/nut04.rs +++ b/crates/cashu/src/nuts/nut04.rs @@ -291,6 +291,16 @@ impl Settings { .position(|settings| &settings.method == method && &settings.unit == unit) .map(|index| self.methods.remove(index)) } + + /// Supported nut04 methods + pub fn supported_methods(&self) -> Vec<&PaymentMethod> { + self.methods.iter().map(|a| &a.method).collect() + } + + /// Supported nut04 units + pub fn supported_units(&self) -> Vec<&CurrencyUnit> { + self.methods.iter().map(|s| &s.unit).collect() + } } #[cfg(test)] diff --git a/crates/cashu/src/nuts/nut05.rs b/crates/cashu/src/nuts/nut05.rs index cd965274..ac799233 100644 --- a/crates/cashu/src/nuts/nut05.rs +++ b/crates/cashu/src/nuts/nut05.rs @@ -105,6 +105,11 @@ impl TryFrom> for MeltRequest { // Basic implementation without trait bounds impl MeltRequest { + /// Quote Id + pub fn quote_id(&self) -> &Q { + &self.quote + } + /// Get inputs (proofs) pub fn inputs(&self) -> &Proofs { &self.inputs @@ -132,7 +137,7 @@ impl MeltRequest { } /// Total [`Amount`] of [`Proofs`] - pub fn proofs_amount(&self) -> Result { + pub fn inputs_amount(&self) -> Result { Amount::try_sum(self.inputs.iter().map(|proof| proof.amount)) .map_err(|_| Error::AmountOverflow) } @@ -355,6 +360,18 @@ pub struct Settings { pub disabled: bool, } +impl Settings { + /// Supported nut05 methods + pub fn supported_methods(&self) -> Vec<&PaymentMethod> { + self.methods.iter().map(|a| &a.method).collect() + } + + /// Supported nut05 units + pub fn supported_units(&self) -> Vec<&CurrencyUnit> { + self.methods.iter().map(|s| &s.unit).collect() + } +} + #[cfg(test)] mod tests { use serde_json::{from_str, json, to_string}; diff --git a/crates/cashu/src/nuts/nut17/mod.rs b/crates/cashu/src/nuts/nut17/mod.rs index 10bde715..da3be3c8 100644 --- a/crates/cashu/src/nuts/nut17/mod.rs +++ b/crates/cashu/src/nuts/nut17/mod.rs @@ -9,6 +9,7 @@ use super::PublicKey; use crate::nuts::{ CurrencyUnit, MeltQuoteBolt11Response, MintQuoteBolt11Response, PaymentMethod, ProofState, }; +use crate::MintQuoteBolt12Response; pub mod ws; @@ -69,6 +70,21 @@ impl SupportedMethods { commands, } } + + /// Create [`SupportedMethods`] for Bolt12 with all supported commands + pub fn default_bolt12(unit: CurrencyUnit) -> Self { + let commands = vec![ + WsCommand::Bolt12MintQuote, + WsCommand::Bolt12MeltQuote, + WsCommand::ProofState, + ]; + + Self { + method: PaymentMethod::Bolt12, + unit, + commands, + } + } } /// WebSocket commands supported by the Cashu mint @@ -82,11 +98,23 @@ pub enum WsCommand { /// Command to request a Lightning payment for melting tokens #[serde(rename = "bolt11_melt_quote")] Bolt11MeltQuote, + /// Websocket support for Bolt12 Mint Quote + #[serde(rename = "bolt12_mint_quote")] + Bolt12MintQuote, + /// Websocket support for Bolt12 Melt Quote + #[serde(rename = "bolt12_melt_quote")] + Bolt12MeltQuote, /// Command to check the state of a proof #[serde(rename = "proof_state")] ProofState, } +impl From> for NotificationPayload { + fn from(mint_quote: MintQuoteBolt12Response) -> NotificationPayload { + NotificationPayload::MintQuoteBolt12Response(mint_quote) + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(bound = "T: Serialize + DeserializeOwned")] #[serde(untagged)] @@ -98,6 +126,8 @@ pub enum NotificationPayload { MeltQuoteBolt11Response(MeltQuoteBolt11Response), /// Mint Quote Bolt11 Response MintQuoteBolt11Response(MintQuoteBolt11Response), + /// Mint Quote Bolt12 Response + MintQuoteBolt12Response(MintQuoteBolt12Response), } impl From for NotificationPayload { @@ -128,6 +158,10 @@ pub enum Notification { MeltQuoteBolt11(Uuid), /// MintQuote id is an Uuid MintQuoteBolt11(Uuid), + /// MintQuote id is an Uuid + MintQuoteBolt12(Uuid), + /// MintQuote id is an Uuid + MeltQuoteBolt12(Uuid), } /// Kind diff --git a/crates/cashu/src/nuts/nut19.rs b/crates/cashu/src/nuts/nut19.rs index 6434fdf9..bef6ded1 100644 --- a/crates/cashu/src/nuts/nut19.rs +++ b/crates/cashu/src/nuts/nut19.rs @@ -55,4 +55,10 @@ pub enum Path { /// Swap #[serde(rename = "/v1/swap")] Swap, + /// Bolt12 Mint + #[serde(rename = "/v1/mint/bolt12")] + MintBolt12, + /// Bolt12 Melt + #[serde(rename = "/v1/melt/bolt12")] + MeltBolt12, } diff --git a/crates/cashu/src/nuts/nut23.rs b/crates/cashu/src/nuts/nut23.rs index e80f480d..9d67fcda 100644 --- a/crates/cashu/src/nuts/nut23.rs +++ b/crates/cashu/src/nuts/nut23.rs @@ -54,10 +54,6 @@ pub enum QuoteState { Unpaid, /// Quote has been paid and wallet can mint Paid, - /// Minting is in progress - /// **Note:** This state is to be used internally but is not part of the - /// nut. - Pending, /// ecash issued for quote Issued, } @@ -67,7 +63,6 @@ impl fmt::Display for QuoteState { match self { Self::Unpaid => write!(f, "UNPAID"), Self::Paid => write!(f, "PAID"), - Self::Pending => write!(f, "PENDING"), Self::Issued => write!(f, "ISSUED"), } } @@ -78,7 +73,6 @@ impl FromStr for QuoteState { fn from_str(state: &str) -> Result { match state { - "PENDING" => Ok(Self::Pending), "PAID" => Ok(Self::Paid), "UNPAID" => Ok(Self::Unpaid), "ISSUED" => Ok(Self::Issued), diff --git a/crates/cashu/src/nuts/nut24.rs b/crates/cashu/src/nuts/nut24.rs new file mode 100644 index 00000000..574dec9f --- /dev/null +++ b/crates/cashu/src/nuts/nut24.rs @@ -0,0 +1,104 @@ +//! Bolt12 +use serde::{Deserialize, Serialize}; +use thiserror::Error; +#[cfg(feature = "mint")] +use uuid::Uuid; + +use super::{CurrencyUnit, MeltOptions, PublicKey}; +use crate::Amount; + +/// NUT18 Error +#[derive(Debug, Error)] +pub enum Error { + /// Unknown Quote State + #[error("Unknown quote state")] + UnknownState, + /// Amount overflow + #[error("Amount Overflow")] + AmountOverflow, + /// Publickey not defined + #[error("Publickey not defined")] + PublickeyUndefined, +} + +/// Mint quote request [NUT-24] +#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))] +pub struct MintQuoteBolt12Request { + /// Amount + pub amount: Option, + /// Unit wallet would like to pay with + pub unit: CurrencyUnit, + /// Memo to create the invoice with + pub description: Option, + /// Pubkey + pub pubkey: PublicKey, +} + +/// Mint quote response [NUT-24] +#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))] +#[serde(bound = "Q: Serialize + for<'a> Deserialize<'a>")] +pub struct MintQuoteBolt12Response { + /// Quote Id + pub quote: Q, + /// Payment request to fulfil + pub request: String, + /// Amount + pub amount: Option, + /// Unit wallet would like to pay with + pub unit: CurrencyUnit, + /// Unix timestamp until the quote is valid + pub expiry: Option, + /// Pubkey + pub pubkey: PublicKey, + /// Amount that has been paid + pub amount_paid: Amount, + /// Amount that has been issued + pub amount_issued: Amount, +} + +#[cfg(feature = "mint")] +impl MintQuoteBolt12Response { + /// Convert the MintQuote with a quote type Q to a String + pub fn to_string_id(&self) -> MintQuoteBolt12Response { + MintQuoteBolt12Response { + quote: self.quote.to_string(), + request: self.request.clone(), + amount: self.amount, + unit: self.unit.clone(), + expiry: self.expiry, + pubkey: self.pubkey, + amount_paid: self.amount_paid, + amount_issued: self.amount_issued, + } + } +} + +#[cfg(feature = "mint")] +impl From> for MintQuoteBolt12Response { + fn from(value: MintQuoteBolt12Response) -> Self { + Self { + quote: value.quote.to_string(), + request: value.request, + expiry: value.expiry, + amount_paid: value.amount_paid, + amount_issued: value.amount_issued, + pubkey: value.pubkey, + amount: value.amount, + unit: value.unit, + } + } +} + +/// Melt quote request [NUT-18] +#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))] +pub struct MeltQuoteBolt12Request { + /// Bolt12 invoice to be paid + pub request: String, + /// Unit wallet would like to pay with + pub unit: CurrencyUnit, + /// Payment Options + pub options: Option, +} diff --git a/crates/cdk-axum/src/bolt12_router.rs b/crates/cdk-axum/src/bolt12_router.rs new file mode 100644 index 00000000..4ee363d8 --- /dev/null +++ b/crates/cdk-axum/src/bolt12_router.rs @@ -0,0 +1,213 @@ +use anyhow::Result; +use axum::extract::{Json, Path, State}; +use axum::response::Response; +#[cfg(feature = "swagger")] +use cdk::error::ErrorResponse; +#[cfg(feature = "auth")] +use cdk::nuts::nut21::{Method, ProtectedEndpoint, RoutePath}; +use cdk::nuts::{ + MeltQuoteBolt11Response, MeltQuoteBolt12Request, MeltRequest, MintQuoteBolt12Request, + MintQuoteBolt12Response, MintRequest, MintResponse, +}; +use paste::paste; +use tracing::instrument; +use uuid::Uuid; + +#[cfg(feature = "auth")] +use crate::auth::AuthHeader; +use crate::{into_response, post_cache_wrapper, MintState}; + +post_cache_wrapper!(post_mint_bolt12, MintRequest, MintResponse); +post_cache_wrapper!( + post_melt_bolt12, + MeltRequest, + MeltQuoteBolt11Response +); + +#[cfg_attr(feature = "swagger", utoipa::path( + get, + context_path = "/v1", + path = "/mint/quote/bolt12", + responses( + (status = 200, description = "Successful response", body = MintQuoteBolt12Response, content_type = "application/json") + ) +))] +/// Get mint bolt12 quote +#[instrument(skip_all, fields(amount = ?payload.amount))] +pub async fn post_mint_bolt12_quote( + #[cfg(feature = "auth")] auth: AuthHeader, + State(state): State, + Json(payload): Json, +) -> Result>, Response> { + #[cfg(feature = "auth")] + { + state + .mint + .verify_auth( + auth.into(), + &ProtectedEndpoint::new(Method::Post, RoutePath::MintQuoteBolt12), + ) + .await + .map_err(into_response)?; + } + + let quote = state + .mint + .get_mint_quote(payload.into()) + .await + .map_err(into_response)?; + + Ok(Json(quote.try_into().map_err(into_response)?)) +} + +#[cfg_attr(feature = "swagger", utoipa::path( + get, + context_path = "/v1", + path = "/mint/quote/bolt12/{quote_id}", + params( + ("quote_id" = String, description = "The quote ID"), + ), + responses( + (status = 200, description = "Successful response", body = MintQuoteBolt12Response, content_type = "application/json"), + (status = 500, description = "Server error", body = ErrorResponse, content_type = "application/json") + ) +))] +/// Get mint bolt12 quote +#[instrument(skip_all, fields(quote_id = ?quote_id))] +pub async fn get_check_mint_bolt12_quote( + #[cfg(feature = "auth")] auth: AuthHeader, + State(state): State, + Path(quote_id): Path, +) -> Result>, Response> { + #[cfg(feature = "auth")] + { + state + .mint + .verify_auth( + auth.into(), + &ProtectedEndpoint::new(Method::Get, RoutePath::MintQuoteBolt12), + ) + .await + .map_err(into_response)?; + } + + let quote = state + .mint + .check_mint_quote("e_id) + .await + .map_err(into_response)?; + + Ok(Json(quote.try_into().map_err(into_response)?)) +} + +#[cfg_attr(feature = "swagger", utoipa::path( + post, + context_path = "/v1", + path = "/mint/bolt12", + request_body(content = MintRequest, description = "Request params", content_type = "application/json"), + responses( + (status = 200, description = "Successful response", body = MintResponse, content_type = "application/json"), + (status = 500, description = "Server error", body = ErrorResponse, content_type = "application/json") + ) +))] +/// Request a quote for melting tokens +#[instrument(skip_all, fields(quote_id = ?payload.quote))] +pub async fn post_mint_bolt12( + #[cfg(feature = "auth")] auth: AuthHeader, + State(state): State, + Json(payload): Json>, +) -> Result, Response> { + #[cfg(feature = "auth")] + { + state + .mint + .verify_auth( + auth.into(), + &ProtectedEndpoint::new(Method::Post, RoutePath::MintBolt12), + ) + .await + .map_err(into_response)?; + } + + let res = state + .mint + .process_mint_request(payload) + .await + .map_err(|err| { + tracing::error!("Could not process mint: {}", err); + into_response(err) + })?; + + Ok(Json(res)) +} + +#[cfg_attr(feature = "swagger", utoipa::path( + post, + context_path = "/v1", + path = "/melt/quote/bolt12", + request_body(content = MeltQuoteBolt12Request, description = "Quote params", content_type = "application/json"), + responses( + (status = 200, description = "Successful response", body = MeltQuoteBolt11Response, content_type = "application/json"), + (status = 500, description = "Server error", body = ErrorResponse, content_type = "application/json") + ) +))] +pub async fn post_melt_bolt12_quote( + #[cfg(feature = "auth")] auth: AuthHeader, + State(state): State, + Json(payload): Json, +) -> Result>, Response> { + #[cfg(feature = "auth")] + { + state + .mint + .verify_auth( + auth.into(), + &ProtectedEndpoint::new(Method::Post, RoutePath::MeltQuoteBolt12), + ) + .await + .map_err(into_response)?; + } + + let quote = state + .mint + .get_melt_quote(payload.into()) + .await + .map_err(into_response)?; + + Ok(Json(quote)) +} + +#[cfg_attr(feature = "swagger", utoipa::path( + post, + context_path = "/v1", + path = "/melt/bolt12", + request_body(content = MeltRequest, description = "Melt params", content_type = "application/json"), + responses( + (status = 200, description = "Successful response", body = MeltQuoteBolt11Response, content_type = "application/json"), + (status = 500, description = "Server error", body = ErrorResponse, content_type = "application/json") + ) +))] +/// Melt tokens for a Bitcoin payment that the mint will make for the user in exchange +/// +/// Requests tokens to be destroyed and sent out via Lightning. +pub async fn post_melt_bolt12( + #[cfg(feature = "auth")] auth: AuthHeader, + State(state): State, + Json(payload): Json>, +) -> Result>, Response> { + #[cfg(feature = "auth")] + { + state + .mint + .verify_auth( + auth.into(), + &ProtectedEndpoint::new(Method::Post, RoutePath::MeltBolt12), + ) + .await + .map_err(into_response)?; + } + + let res = state.mint.melt(&payload).await.map_err(into_response)?; + + Ok(Json(res)) +} diff --git a/crates/cdk-axum/src/lib.rs b/crates/cdk-axum/src/lib.rs index 230ba7ae..9863c98d 100644 --- a/crates/cdk-axum/src/lib.rs +++ b/crates/cdk-axum/src/lib.rs @@ -19,6 +19,7 @@ use router_handlers::*; #[cfg(feature = "auth")] mod auth; +mod bolt12_router; pub mod cache; mod router_handlers; mod ws; @@ -52,6 +53,11 @@ mod swagger_imports { #[cfg(feature = "swagger")] use swagger_imports::*; +use crate::bolt12_router::{ + cache_post_melt_bolt12, cache_post_mint_bolt12, get_check_mint_bolt12_quote, + post_melt_bolt12_quote, post_mint_bolt12_quote, +}; + /// CDK Mint State #[derive(Clone)] pub struct MintState { @@ -134,8 +140,8 @@ pub struct MintState { pub struct ApiDocV1; /// Create mint [`Router`] with required endpoints for cashu mint with the default cache -pub async fn create_mint_router(mint: Arc) -> Result { - create_mint_router_with_custom_cache(mint, Default::default()).await +pub async fn create_mint_router(mint: Arc, include_bolt12: bool) -> Result { + create_mint_router_with_custom_cache(mint, Default::default(), include_bolt12).await } async fn cors_middleware( @@ -187,6 +193,7 @@ async fn cors_middleware( pub async fn create_mint_router_with_custom_cache( mint: Arc, cache: HttpCache, + include_bolt12: bool, ) -> Result { let state = MintState { mint, @@ -223,9 +230,34 @@ pub async fn create_mint_router_with_custom_cache( mint_router.nest("/v1", auth_router) }; - let mint_router = mint_router.layer(from_fn(cors_middleware)); + // Conditionally create and merge bolt12_router + let mint_router = if include_bolt12 { + let bolt12_router = create_bolt12_router(state.clone()); + mint_router.nest("/v1", bolt12_router) + } else { + mint_router + }; - let mint_router = mint_router.with_state(state); + let mint_router = mint_router + .layer(from_fn(cors_middleware)) + .with_state(state); Ok(mint_router) } + +fn create_bolt12_router(state: MintState) -> Router { + Router::new() + .route("/melt/quote/bolt12", post(post_melt_bolt12_quote)) + .route( + "/melt/quote/bolt12/{quote_id}", + get(get_check_melt_bolt11_quote), + ) + .route("/melt/bolt12", post(cache_post_melt_bolt12)) + .route("/mint/quote/bolt12", post(post_mint_bolt12_quote)) + .route( + "/mint/quote/bolt12/{quote_id}", + get(get_check_mint_bolt12_quote), + ) + .route("/mint/bolt12", post(cache_post_mint_bolt12)) + .with_state(state) +} diff --git a/crates/cdk-axum/src/router_handlers.rs b/crates/cdk-axum/src/router_handlers.rs index 0fb937ed..1deee567 100644 --- a/crates/cdk-axum/src/router_handlers.rs +++ b/crates/cdk-axum/src/router_handlers.rs @@ -22,6 +22,8 @@ use crate::auth::AuthHeader; use crate::ws::main_websocket; use crate::MintState; +/// Macro to add cache to endpoint +#[macro_export] macro_rules! post_cache_wrapper { ($handler:ident, $request_type:ty, $response_type:ty) => { paste! { @@ -163,11 +165,11 @@ pub(crate) async fn post_mint_bolt11_quote( let quote = state .mint - .get_mint_bolt11_quote(payload) + .get_mint_quote(payload.into()) .await .map_err(into_response)?; - Ok(Json(quote)) + Ok(Json(quote.try_into().map_err(into_response)?)) } #[cfg_attr(feature = "swagger", utoipa::path( @@ -212,7 +214,7 @@ pub(crate) async fn get_check_mint_bolt11_quote( into_response(err) })?; - Ok(Json(quote)) + Ok(Json(quote.try_into().map_err(into_response)?)) } #[instrument(skip_all)] @@ -299,7 +301,7 @@ pub(crate) async fn post_melt_bolt11_quote( let quote = state .mint - .get_melt_bolt11_quote(&payload) + .get_melt_quote(payload.into()) .await .map_err(into_response)?; @@ -382,11 +384,7 @@ pub(crate) async fn post_melt_bolt11( .map_err(into_response)?; } - let res = state - .mint - .melt_bolt11(&payload) - .await - .map_err(into_response)?; + let res = state.mint.melt(&payload).await.map_err(into_response)?; Ok(Json(res)) } diff --git a/crates/cdk-cli/Cargo.toml b/crates/cdk-cli/Cargo.toml index 6ec4c8b2..e6e83a28 100644 --- a/crates/cdk-cli/Cargo.toml +++ b/crates/cdk-cli/Cargo.toml @@ -11,6 +11,8 @@ rust-version.workspace = true readme = "README.md" [features] +default = ["bip353"] +bip353 = ["dep:trust-dns-resolver"] sqlcipher = ["cdk-sqlite/sqlcipher"] # MSRV is not tracked with redb enabled redb = ["dep:cdk-redb"] @@ -37,3 +39,5 @@ nostr-sdk = { version = "0.41.0", default-features = false, features = [ reqwest.workspace = true url.workspace = true serde_with.workspace = true +lightning.workspace = true +trust-dns-resolver = { version = "0.23.2", optional = true } diff --git a/crates/cdk-cli/src/bip353.rs b/crates/cdk-cli/src/bip353.rs new file mode 100644 index 00000000..1d424be4 --- /dev/null +++ b/crates/cdk-cli/src/bip353.rs @@ -0,0 +1,132 @@ +use std::collections::HashMap; +use std::str::FromStr; + +use anyhow::{bail, Result}; +use trust_dns_resolver::config::{ResolverConfig, ResolverOpts}; +use trust_dns_resolver::TokioAsyncResolver; + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Bip353Address { + pub user: String, + pub domain: String, +} + +impl Bip353Address { + /// Resolve a human-readable Bitcoin address + pub async fn resolve(self) -> Result { + // Construct DNS name + let dns_name = format!("{}.user._bitcoin-payment.{}", self.user, self.domain); + + // Create a new resolver with DNSSEC validation + let mut opts = ResolverOpts::default(); + opts.validate = true; // Enable DNSSEC validation + + let resolver = TokioAsyncResolver::tokio(ResolverConfig::default(), opts); + + // Query TXT records - with opts.validate=true, this will fail if DNSSEC validation fails + let response = resolver.txt_lookup(&dns_name).await?; + + // Extract and concatenate TXT record strings + let mut bitcoin_uris = Vec::new(); + + for txt in response.iter() { + let txt_data: Vec = txt + .txt_data() + .iter() + .map(|bytes| String::from_utf8_lossy(bytes).into_owned()) + .collect(); + + let concatenated = txt_data.join(""); + + if concatenated.to_lowercase().starts_with("bitcoin:") { + bitcoin_uris.push(concatenated); + } + } + + // BIP-353 requires exactly one Bitcoin URI + match bitcoin_uris.len() { + 0 => bail!("No Bitcoin URI found"), + 1 => PaymentInstruction::from_uri(&bitcoin_uris[0]), + _ => bail!("Multiple Bitcoin URIs found"), + } + } +} + +impl FromStr for Bip353Address { + type Err = anyhow::Error; + + /// Parse a human-readable Bitcoin address + fn from_str(address: &str) -> Result { + let addr = address.trim(); + + // Remove Bitcoin prefix if present + let addr = addr.strip_prefix("₿").unwrap_or(addr); + + // Split by @ + let parts: Vec<&str> = addr.split('@').collect(); + if parts.len() != 2 { + bail!("Address is not formatted correctly") + } + + let user = parts[0].trim(); + let domain = parts[1].trim(); + + if user.is_empty() || domain.is_empty() { + bail!("User name and domain must not be empty") + } + + Ok(Self { + user: user.to_string(), + domain: domain.to_string(), + }) + } +} + +/// Payment instruction type +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum PaymentType { + OnChain, + LightningOffer, +} + +/// BIP-353 payment instruction +#[derive(Debug, Clone)] +pub struct PaymentInstruction { + pub parameters: HashMap, +} + +impl PaymentInstruction { + /// Parse a payment instruction from a Bitcoin URI + pub fn from_uri(uri: &str) -> Result { + if !uri.to_lowercase().starts_with("bitcoin:") { + bail!("URI must start with 'bitcoin:'") + } + + let mut parameters = HashMap::new(); + + // Parse URI parameters + if let Some(query_start) = uri.find('?') { + let query = &uri[query_start + 1..]; + for pair in query.split('&') { + if let Some(eq_pos) = pair.find('=') { + let key = pair[..eq_pos].to_string(); + let value = pair[eq_pos + 1..].to_string(); + let payment_type; + // Determine payment type + if key.contains("lno") { + payment_type = PaymentType::LightningOffer; + } else if !uri[8..].contains('?') && uri.len() > 8 { + // Simple on-chain address + payment_type = PaymentType::OnChain; + } else { + continue; + } + + parameters.insert(payment_type, value); + } + } + } + + Ok(PaymentInstruction { parameters }) + } +} diff --git a/crates/cdk-cli/src/main.rs b/crates/cdk-cli/src/main.rs index 252be05b..7eb554b5 100644 --- a/crates/cdk-cli/src/main.rs +++ b/crates/cdk-cli/src/main.rs @@ -18,6 +18,8 @@ use tracing::Level; use tracing_subscriber::EnvFilter; use url::Url; +#[cfg(feature = "bip353")] +mod bip353; mod nostr_storage; mod sub_commands; mod token_storage; diff --git a/crates/cdk-cli/src/sub_commands/melt.rs b/crates/cdk-cli/src/sub_commands/melt.rs index bd92dde9..ea2bd676 100644 --- a/crates/cdk-cli/src/sub_commands/melt.rs +++ b/crates/cdk-cli/src/sub_commands/melt.rs @@ -1,20 +1,34 @@ use std::str::FromStr; -use anyhow::{bail, Result}; -use cdk::amount::MSAT_IN_SAT; +use anyhow::{anyhow, bail, Result}; +use cdk::amount::{amount_for_offer, Amount, MSAT_IN_SAT}; +use cdk::mint_url::MintUrl; use cdk::nuts::{CurrencyUnit, MeltOptions}; use cdk::wallet::multi_mint_wallet::MultiMintWallet; use cdk::wallet::types::WalletKey; +use cdk::wallet::{MeltQuote, Wallet}; use cdk::Bolt11Invoice; -use clap::Args; +use clap::{Args, ValueEnum}; +use lightning::offers::offer::Offer; use tokio::task::JoinSet; +use crate::bip353::{Bip353Address, PaymentType as Bip353PaymentType}; use crate::sub_commands::balance::mint_balances; use crate::utils::{ get_number_input, get_user_input, get_wallet_by_index, get_wallet_by_mint_url, validate_mint_number, }; +#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)] +pub enum PaymentType { + /// BOLT11 invoice + Bolt11, + /// BOLT12 offer + Bolt12, + /// Bip353 + Bip353, +} + #[derive(Args)] pub struct MeltSubCommand { /// Currency unit e.g. sat @@ -26,6 +40,56 @@ pub struct MeltSubCommand { /// Mint URL to use for melting #[arg(long, conflicts_with = "mpp")] mint_url: Option, + /// Payment method (bolt11 or bolt12) + #[arg(long, default_value = "bolt11")] + method: PaymentType, +} + +/// Helper function to process a melt quote and execute the payment +async fn process_payment(wallet: &Wallet, quote: MeltQuote) -> Result<()> { + // Display quote information + println!("Quote ID: {}", quote.id); + println!("Amount: {}", quote.amount); + println!("Fee Reserve: {}", quote.fee_reserve); + println!("State: {}", quote.state); + println!("Expiry: {}", quote.expiry); + + // Execute the payment + let melt = wallet.melt("e.id).await?; + println!("Paid: {}", melt.state); + + if let Some(preimage) = melt.preimage { + println!("Payment preimage: {preimage}"); + } + + Ok(()) +} + +/// Helper function to check if there are enough funds and create appropriate MeltOptions +fn create_melt_options( + available_funds: u64, + payment_amount: Option, + prompt: &str, +) -> Result> { + match payment_amount { + Some(amount) => { + // Payment has a specified amount + if amount > available_funds { + bail!("Not enough funds; payment requires {} msats", amount); + } + Ok(None) // Use default options + } + None => { + // Payment doesn't have an amount, ask user for it + let user_amount = get_number_input::(prompt)? * MSAT_IN_SAT; + + if user_amount > available_funds { + bail!("Not enough funds"); + } + + Ok(Some(MeltOptions::new_amountless(user_amount))) + } + } } pub async fn pay( @@ -35,123 +99,31 @@ pub async fn pay( let unit = CurrencyUnit::from_str(&sub_command_args.unit)?; let mints_amounts = mint_balances(multi_mint_wallet, &unit).await?; - let mut mints = vec![]; - let mut mint_amounts = vec![]; if sub_command_args.mpp { - // MPP functionality expects multiple mints, so mint_url flag doesn't fully apply here, - // but we can offer to use the specified mint as the first one if provided - if let Some(mint_url) = &sub_command_args.mint_url { - println!("Using mint URL {mint_url} as the first mint for MPP payment."); - - // Check if the mint exists - if let Ok(_wallet) = - get_wallet_by_mint_url(multi_mint_wallet, mint_url, unit.clone()).await - { - // Find the index of this mint in the mints_amounts list - if let Some(mint_index) = mints_amounts - .iter() - .position(|(url, _)| url.to_string() == *mint_url) - { - mints.push(mint_index); - let melt_amount: u64 = - get_number_input("Enter amount to mint from this mint in sats.")?; - mint_amounts.push(melt_amount); - } else { - println!("Warning: Mint URL exists but no balance found. Continuing with manual selection."); - } - } else { - println!("Warning: Could not find wallet for the specified mint URL. Continuing with manual selection."); - } - } - loop { - let mint_number: String = - get_user_input("Enter mint number to melt from and -1 when done.")?; - - if mint_number == "-1" || mint_number.is_empty() { - break; - } - - let mint_number: usize = mint_number.parse()?; - validate_mint_number(mint_number, mints_amounts.len())?; - - mints.push(mint_number); - let melt_amount: u64 = - get_number_input("Enter amount to mint from this mint in sats.")?; - mint_amounts.push(melt_amount); + // MPP logic only works with BOLT11 currently + if !matches!(sub_command_args.method, PaymentType::Bolt11) { + bail!("MPP is only supported for BOLT11 invoices"); } + // Collect mint numbers and amounts for MPP + let (mints, mint_amounts) = collect_mpp_inputs(&mints_amounts, &sub_command_args.mint_url)?; + + // Process BOLT11 MPP payment let bolt11 = Bolt11Invoice::from_str(&get_user_input("Enter bolt11 invoice request")?)?; - let mut quotes = JoinSet::new(); + // Get quotes from all mints + let quotes = get_mpp_quotes( + multi_mint_wallet, + &mints_amounts, + &mints, + &mint_amounts, + &unit, + &bolt11, + ) + .await?; - for (mint, amount) in mints.iter().zip(mint_amounts) { - let wallet = mints_amounts[*mint].0.clone(); - - let wallet = multi_mint_wallet - .get_wallet(&WalletKey::new(wallet, unit.clone())) - .await - .expect("Known wallet"); - let options = MeltOptions::new_mpp(amount * 1000); - - let bolt11_clone = bolt11.clone(); - - quotes.spawn(async move { - let quote = wallet - .melt_quote(bolt11_clone.to_string(), Some(options)) - .await; - - (wallet, quote) - }); - } - - let quotes = quotes.join_all().await; - - for (wallet, quote) in quotes.iter() { - if let Err(quote) = quote { - tracing::error!("Could not get quote for {}: {:?}", wallet.mint_url, quote); - bail!("Could not get melt quote for {}", wallet.mint_url); - } else { - let quote = quote.as_ref().unwrap(); - println!( - "Melt quote {} for mint {} of amount {} with fee {}.", - quote.id, wallet.mint_url, quote.amount, quote.fee_reserve - ); - } - } - - let mut melts = JoinSet::new(); - - for (wallet, quote) in quotes { - let quote = quote.expect("Errors checked above"); - - melts.spawn(async move { - let melt = wallet.melt("e.id).await; - (wallet, melt) - }); - } - - let melts = melts.join_all().await; - - let mut error = false; - - for (wallet, melt) in melts { - match melt { - Ok(melt) => { - println!( - "Melt for {} paid {} with fee of {} ", - wallet.mint_url, melt.amount, melt.fee_paid - ); - } - Err(err) => { - println!("Melt for {} failed with {}", wallet.mint_url, err); - error = true; - } - } - } - - if error { - bail!("Could not complete all melts"); - } + // Execute all melts + execute_mpp_melts(quotes).await?; } else { // Get wallet either by mint URL or by index let wallet = if let Some(mint_url) = &sub_command_args.mint_url { @@ -174,47 +146,207 @@ pub async fn pay( let available_funds = >::into(mint_amount) * MSAT_IN_SAT; - let bolt11 = Bolt11Invoice::from_str(&get_user_input("Enter bolt11 invoice request")?)?; + // Process payment based on payment method + match sub_command_args.method { + PaymentType::Bolt11 => { + // Process BOLT11 payment + let bolt11 = Bolt11Invoice::from_str(&get_user_input("Enter bolt11 invoice")?)?; - // Determine payment amount and options - let options = if bolt11.amount_milli_satoshis().is_none() { - // Get user input for amount - let prompt = format!( - "Enter the amount you would like to pay in sats for a {} payment.", - if sub_command_args.mpp { - "MPP" - } else { - "amountless invoice" - } - ); + // Determine payment amount and options + let prompt = + "Enter the amount you would like to pay in sats for this amountless invoice."; + let options = + create_melt_options(available_funds, bolt11.amount_milli_satoshis(), prompt)?; - let user_amount = get_number_input::(&prompt)? * MSAT_IN_SAT; - - if user_amount > available_funds { - bail!("Not enough funds"); + // Process payment + let quote = wallet.melt_quote(bolt11.to_string(), options).await?; + process_payment(&wallet, quote).await?; } + PaymentType::Bolt12 => { + // Process BOLT12 payment (offer) + let offer_str = get_user_input("Enter BOLT12 offer")?; + let offer = Offer::from_str(&offer_str) + .map_err(|e| anyhow::anyhow!("Invalid BOLT12 offer: {:?}", e))?; - Some(MeltOptions::new_amountless(user_amount)) - } else { - // Check if invoice amount exceeds available funds - let invoice_amount = bolt11.amount_milli_satoshis().unwrap(); - if invoice_amount > available_funds { - bail!("Not enough funds"); + // Determine if offer has an amount + let prompt = + "Enter the amount you would like to pay in sats for this amountless offer:"; + let amount_msat = match amount_for_offer(&offer, &CurrencyUnit::Msat) { + Ok(amount) => Some(u64::from(amount)), + Err(_) => None, + }; + + let options = create_melt_options(available_funds, amount_msat, prompt)?; + + // Get melt quote for BOLT12 + let quote = wallet.melt_bolt12_quote(offer_str, options).await?; + process_payment(&wallet, quote).await?; } - None - }; + PaymentType::Bip353 => { + let bip353_addr = get_user_input("Enter Bip353 address.")?; + let bip353_addr = Bip353Address::from_str(&bip353_addr)?; - // Process payment - let quote = wallet.melt_quote(bolt11.to_string(), options).await?; - println!("{quote:?}"); + let payment_instructions = bip353_addr.resolve().await?; - let melt = wallet.melt("e.id).await?; - println!("Paid invoice: {}", melt.state); + let offer = payment_instructions + .parameters + .get(&Bip353PaymentType::LightningOffer) + .ok_or(anyhow!("Offer not defined"))?; - if let Some(preimage) = melt.preimage { - println!("Payment preimage: {preimage}"); + let prompt = + "Enter the amount you would like to pay in sats for this amountless offer:"; + // BIP353 payments are always amountless for now + let options = create_melt_options(available_funds, None, prompt)?; + + // Get melt quote for BOLT12 + let quote = wallet.melt_bolt12_quote(offer.to_string(), options).await?; + process_payment(&wallet, quote).await?; + } } } Ok(()) } + +/// Collect mint numbers and amounts for MPP payments +fn collect_mpp_inputs( + mints_amounts: &[(MintUrl, Amount)], + mint_url_opt: &Option, +) -> Result<(Vec, Vec)> { + let mut mints = Vec::new(); + let mut mint_amounts = Vec::new(); + + // If a specific mint URL was provided, try to use it as the first mint + if let Some(mint_url) = mint_url_opt { + println!("Using mint URL {mint_url} as the first mint for MPP payment."); + + // Find the index of this mint in the mints_amounts list + if let Some(mint_index) = mints_amounts + .iter() + .position(|(url, _)| url.to_string() == *mint_url) + { + mints.push(mint_index); + let melt_amount: u64 = + get_number_input("Enter amount to mint from this mint in sats.")?; + mint_amounts.push(melt_amount); + } else { + println!( + "Warning: Mint URL not found or no balance. Continuing with manual selection." + ); + } + } + + // Continue with regular mint selection + loop { + let mint_number: String = + get_user_input("Enter mint number to melt from and -1 when done.")?; + + if mint_number == "-1" || mint_number.is_empty() { + break; + } + + let mint_number: usize = mint_number.parse()?; + validate_mint_number(mint_number, mints_amounts.len())?; + + mints.push(mint_number); + let melt_amount: u64 = get_number_input("Enter amount to mint from this mint in sats.")?; + mint_amounts.push(melt_amount); + } + + if mints.is_empty() { + bail!("No mints selected for MPP payment"); + } + + Ok((mints, mint_amounts)) +} + +/// Get quotes from all mints for MPP payment +async fn get_mpp_quotes( + multi_mint_wallet: &MultiMintWallet, + mints_amounts: &[(MintUrl, Amount)], + mints: &[usize], + mint_amounts: &[u64], + unit: &CurrencyUnit, + bolt11: &Bolt11Invoice, +) -> Result> { + let mut quotes = JoinSet::new(); + + for (mint, amount) in mints.iter().zip(mint_amounts) { + let wallet = mints_amounts[*mint].0.clone(); + + let wallet = multi_mint_wallet + .get_wallet(&WalletKey::new(wallet, unit.clone())) + .await + .expect("Known wallet"); + let options = MeltOptions::new_mpp(*amount * 1000); + + let bolt11_clone = bolt11.clone(); + + quotes.spawn(async move { + let quote = wallet + .melt_quote(bolt11_clone.to_string(), Some(options)) + .await; + + (wallet, quote) + }); + } + + let quotes_results = quotes.join_all().await; + + // Validate all quotes succeeded + let mut valid_quotes = Vec::new(); + for (wallet, quote_result) in quotes_results { + match quote_result { + Ok(quote) => { + println!( + "Melt quote {} for mint {} of amount {} with fee {}.", + quote.id, wallet.mint_url, quote.amount, quote.fee_reserve + ); + valid_quotes.push((wallet, quote)); + } + Err(err) => { + tracing::error!("Could not get quote for {}: {:?}", wallet.mint_url, err); + bail!("Could not get melt quote for {}", wallet.mint_url); + } + } + } + + Ok(valid_quotes) +} + +/// Execute all melts for MPP payment +async fn execute_mpp_melts(quotes: Vec<(Wallet, MeltQuote)>) -> Result<()> { + let mut melts = JoinSet::new(); + + for (wallet, quote) in quotes { + melts.spawn(async move { + let melt = wallet.melt("e.id).await; + (wallet, melt) + }); + } + + let melts = melts.join_all().await; + + let mut error = false; + + for (wallet, melt) in melts { + match melt { + Ok(melt) => { + println!( + "Melt for {} paid {} with fee of {} ", + wallet.mint_url, melt.amount, melt.fee_paid + ); + } + Err(err) => { + println!("Melt for {} failed with {}", wallet.mint_url, err); + error = true; + } + } + } + + if error { + bail!("Could not complete all melts"); + } + + Ok(()) +} diff --git a/crates/cdk-cli/src/sub_commands/mint.rs b/crates/cdk-cli/src/sub_commands/mint.rs index 9372bd58..71b2a987 100644 --- a/crates/cdk-cli/src/sub_commands/mint.rs +++ b/crates/cdk-cli/src/sub_commands/mint.rs @@ -4,7 +4,7 @@ use anyhow::{anyhow, Result}; use cdk::amount::SplitTarget; use cdk::mint_url::MintUrl; use cdk::nuts::nut00::ProofsMethods; -use cdk::nuts::{CurrencyUnit, MintQuoteState, NotificationPayload}; +use cdk::nuts::{CurrencyUnit, MintQuoteState, NotificationPayload, PaymentMethod}; use cdk::wallet::{MultiMintWallet, WalletSubscription}; use cdk::Amount; use clap::Args; @@ -27,6 +27,15 @@ pub struct MintSubCommand { /// Quote Id #[arg(short, long)] quote_id: Option, + /// Payment method + #[arg(long, default_value = "bolt11")] + method: String, + /// Expiry + #[arg(short, long)] + expiry: Option, + /// Expiry + #[arg(short, long)] + single_use: Option, } pub async fn mint( @@ -39,36 +48,104 @@ pub async fn mint( let wallet = get_or_create_wallet(multi_mint_wallet, &mint_url, unit).await?; + let mut payment_method = PaymentMethod::from_str(&sub_command_args.method)?; + let quote_id = match &sub_command_args.quote_id { - None => { - let amount = sub_command_args - .amount - .ok_or(anyhow!("Amount must be defined"))?; - let quote = wallet.mint_quote(Amount::from(amount), description).await?; + None => match payment_method { + PaymentMethod::Bolt11 => { + let amount = sub_command_args + .amount + .ok_or(anyhow!("Amount must be defined"))?; + let quote = wallet.mint_quote(Amount::from(amount), description).await?; - println!("Quote: {quote:#?}"); + println!("Quote: {quote:#?}"); - println!("Please pay: {}", quote.request); + println!("Please pay: {}", quote.request); - let mut subscription = wallet - .subscribe(WalletSubscription::Bolt11MintQuoteState(vec![quote - .id - .clone()])) - .await; + let mut subscription = wallet + .subscribe(WalletSubscription::Bolt11MintQuoteState(vec![quote + .id + .clone()])) + .await; - while let Some(msg) = subscription.recv().await { - if let NotificationPayload::MintQuoteBolt11Response(response) = msg { - if response.state == MintQuoteState::Paid { - break; + while let Some(msg) = subscription.recv().await { + if let NotificationPayload::MintQuoteBolt11Response(response) = msg { + if response.state == MintQuoteState::Paid { + break; + } } } + quote.id } - quote.id + PaymentMethod::Bolt12 => { + let amount = sub_command_args.amount; + println!("{:?}", sub_command_args.single_use); + let quote = wallet + .mint_bolt12_quote(amount.map(|a| a.into()), description) + .await?; + + println!("Quote: {quote:#?}"); + + println!("Please pay: {}", quote.request); + + let mut subscription = wallet + .subscribe(WalletSubscription::Bolt11MintQuoteState(vec![quote + .id + .clone()])) + .await; + + while let Some(msg) = subscription.recv().await { + if let NotificationPayload::MintQuoteBolt11Response(response) = msg { + if response.state == MintQuoteState::Paid { + break; + } + } + } + quote.id + } + _ => { + todo!() + } + }, + Some(quote_id) => { + let quote = wallet + .localstore + .get_mint_quote(quote_id) + .await? + .ok_or(anyhow!("Unknown quote"))?; + + payment_method = quote.payment_method; + quote_id.to_string() } - Some(quote_id) => quote_id.to_string(), }; - let proofs = wallet.mint("e_id, SplitTarget::default(), None).await?; + tracing::debug!("Attempting mint for: {}", payment_method); + + let proofs = match payment_method { + PaymentMethod::Bolt11 => wallet.mint("e_id, SplitTarget::default(), None).await?, + PaymentMethod::Bolt12 => { + let response = wallet.mint_bolt12_quote_state("e_id).await?; + + let amount_mintable = response.amount_paid - response.amount_issued; + + if amount_mintable == Amount::ZERO { + println!("Mint quote does not have amount that can be minted."); + return Ok(()); + } + + wallet + .mint_bolt12( + "e_id, + Some(amount_mintable), + SplitTarget::default(), + None, + ) + .await? + } + _ => { + todo!() + } + }; let receive_amount = proofs.total_amount()?; diff --git a/crates/cdk-cln/src/error.rs b/crates/cdk-cln/src/error.rs index 7fd489ff..2f9d8e0e 100644 --- a/crates/cdk-cln/src/error.rs +++ b/crates/cdk-cln/src/error.rs @@ -26,6 +26,12 @@ pub enum Error { /// Amount Error #[error(transparent)] Amount(#[from] cdk_common::amount::Error), + /// UTF-8 Error + #[error(transparent)] + Utf8(#[from] std::string::FromUtf8Error), + /// Bolt12 Error + #[error("Bolt12 error: {0}")] + Bolt12(String), } impl From for cdk_common::payment::Error { diff --git a/crates/cdk-cln/src/lib.rs b/crates/cdk-cln/src/lib.rs index 916e30e3..a52dae61 100644 --- a/crates/cdk-cln/src/lib.rs +++ b/crates/cdk-cln/src/lib.rs @@ -10,29 +10,35 @@ use std::pin::Pin; use std::str::FromStr; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; +use std::time::Duration; use async_trait::async_trait; +use bitcoin::hashes::sha256::Hash; use cdk_common::amount::{to_unit, Amount}; use cdk_common::common::FeeReserve; -use cdk_common::nuts::{CurrencyUnit, MeltOptions, MeltQuoteState, MintQuoteState}; +use cdk_common::nuts::{CurrencyUnit, MeltOptions, MeltQuoteState}; use cdk_common::payment::{ - self, Bolt11Settings, CreateIncomingPaymentResponse, MakePaymentResponse, MintPayment, - PaymentQuoteResponse, + self, Bolt11IncomingPaymentOptions, Bolt11Settings, Bolt12IncomingPaymentOptions, + CreateIncomingPaymentResponse, IncomingPaymentOptions, MakePaymentResponse, MintPayment, + OutgoingPaymentOptions, PaymentIdentifier, PaymentQuoteResponse, WaitPaymentResponse, }; use cdk_common::util::{hex, unix_time}; -use cdk_common::{mint, Bolt11Invoice}; +use cdk_common::Bolt11Invoice; use cln_rpc::model::requests::{ - InvoiceRequest, ListinvoicesRequest, ListpaysRequest, PayRequest, WaitanyinvoiceRequest, + DecodeRequest, FetchinvoiceRequest, InvoiceRequest, ListinvoicesRequest, ListpaysRequest, + OfferRequest, PayRequest, WaitanyinvoiceRequest, }; use cln_rpc::model::responses::{ - ListinvoicesInvoices, ListinvoicesInvoicesStatus, ListpaysPaysStatus, PayStatus, - WaitanyinvoiceStatus, + DecodeResponse, ListinvoicesInvoices, ListinvoicesInvoicesStatus, ListpaysPaysStatus, + PayStatus, WaitanyinvoiceResponse, WaitanyinvoiceStatus, }; -use cln_rpc::primitives::{Amount as CLN_Amount, AmountOrAny}; +use cln_rpc::primitives::{Amount as CLN_Amount, AmountOrAny, Sha256}; +use cln_rpc::ClnRpc; use error::Error; use futures::{Stream, StreamExt}; use serde_json::Value; use tokio_util::sync::CancellationToken; +use tracing::instrument; use uuid::Uuid; pub mod error; @@ -68,6 +74,7 @@ impl MintPayment for Cln { unit: CurrencyUnit::Msat, invoice_description: true, amountless: true, + bolt12: true, })?) } @@ -81,12 +88,33 @@ impl MintPayment for Cln { self.wait_invoice_cancel_token.cancel() } + #[instrument(skip_all)] async fn wait_any_incoming_payment( &self, - ) -> Result + Send>>, Self::Err> { - let last_pay_index = self.get_last_pay_index().await?; - let cln_client = cln_rpc::ClnRpc::new(&self.rpc_socket).await?; + ) -> Result + Send>>, Self::Err> { + tracing::info!( + "CLN: Starting wait_any_incoming_payment with socket: {:?}", + self.rpc_socket + ); + let last_pay_index = self.get_last_pay_index().await?.map(|idx| { + tracing::info!("CLN: Found last payment index: {}", idx); + idx + }); + + tracing::debug!("CLN: Connecting to CLN node..."); + let cln_client = match cln_rpc::ClnRpc::new(&self.rpc_socket).await { + Ok(client) => { + tracing::debug!("CLN: Successfully connected to CLN node"); + client + } + Err(err) => { + tracing::error!("CLN: Failed to connect to CLN node: {}", err); + return Err(Error::from(err).into()); + } + }; + + tracing::debug!("CLN: Creating stream processing pipeline"); let stream = futures::stream::unfold( ( cln_client, @@ -97,70 +125,133 @@ impl MintPayment for Cln { |(mut cln_client, mut last_pay_idx, cancel_token, is_active)| async move { // Set the stream as active is_active.store(true, Ordering::SeqCst); + tracing::debug!("CLN: Stream is now active, waiting for invoice events with lastpay_index: {:?}", last_pay_idx); loop { - let request = WaitanyinvoiceRequest { - timeout: None, - lastpay_index: last_pay_idx, - }; tokio::select! { _ = cancel_token.cancelled() => { // Set the stream as inactive is_active.store(false, Ordering::SeqCst); + tracing::info!("CLN: Invoice stream cancelled"); // End the stream return None; } - result = cln_client.call_typed(&request) => { + result = cln_client.call(cln_rpc::Request::WaitAnyInvoice(WaitanyinvoiceRequest { + timeout: None, + lastpay_index: last_pay_idx, + })) => { + tracing::debug!("CLN: Received response from WaitAnyInvoice call"); match result { Ok(invoice) => { + tracing::debug!("CLN: Successfully received invoice data"); + // Try to convert the invoice to WaitanyinvoiceResponse + let wait_any_response_result: Result = + invoice.try_into(); + + let wait_any_response = match wait_any_response_result { + Ok(response) => { + tracing::debug!("CLN: Parsed WaitAnyInvoice response successfully"); + response + } + Err(e) => { + tracing::warn!( + "CLN: Failed to parse WaitAnyInvoice response: {:?}", + e + ); + // Continue to the next iteration without panicking + continue; + } + }; // Check the status of the invoice // We only want to yield invoices that have been paid - match invoice.status { - WaitanyinvoiceStatus::PAID => (), - WaitanyinvoiceStatus::EXPIRED => continue, + match wait_any_response.status { + WaitanyinvoiceStatus::PAID => { + tracing::info!("CLN: Invoice with payment index {} is PAID", + wait_any_response.pay_index.unwrap_or_default()); + } + WaitanyinvoiceStatus::EXPIRED => { + tracing::debug!("CLN: Invoice with payment index {} is EXPIRED, skipping", + wait_any_response.pay_index.unwrap_or_default()); + continue; + } } - last_pay_idx = invoice.pay_index; + last_pay_idx = wait_any_response.pay_index; + tracing::debug!("CLN: Updated last_pay_idx to {:?}", last_pay_idx); - let payment_hash = invoice.payment_hash.to_string(); + let payment_hash = wait_any_response.payment_hash; + tracing::debug!("CLN: Payment hash: {}", payment_hash); - let request_look_up = match invoice.bolt12 { + let amount_msats = match wait_any_response.amount_received_msat { + Some(amt) => { + tracing::info!("CLN: Received payment of {} msats for {}", + amt.msat(), payment_hash); + amt + } + None => { + tracing::error!("CLN: No amount in paid invoice, this should not happen"); + continue; + } + }; + let amount_sats = amount_msats.msat() / 1000; + + let payment_hash = Hash::from_bytes_ref(payment_hash.as_ref()); + + let request_lookup_id = match wait_any_response.bolt12 { // If it is a bolt12 payment we need to get the offer_id as this is what we use as the request look up. // Since this is not returned in the wait any response, // we need to do a second query for it. - Some(_) => { + Some(bolt12) => { + tracing::info!("CLN: Processing BOLT12 payment, bolt12 value: {}", bolt12); match fetch_invoice_by_payment_hash( &mut cln_client, - &payment_hash, + payment_hash, ) .await { Ok(Some(invoice)) => { if let Some(local_offer_id) = invoice.local_offer_id { - local_offer_id.to_string() + tracing::info!("CLN: Received bolt12 payment of {} sats for offer {}", + amount_sats, local_offer_id); + PaymentIdentifier::OfferId(local_offer_id.to_string()) } else { + tracing::warn!("CLN: BOLT12 invoice has no local_offer_id, skipping"); continue; } } - Ok(None) => continue, + Ok(None) => { + tracing::warn!("CLN: Failed to find invoice by payment hash, skipping"); + continue; + } Err(e) => { tracing::warn!( - "Error fetching invoice by payment hash: {e}" + "CLN: Error fetching invoice by payment hash: {e}" ); continue; } } } - None => payment_hash, + None => { + tracing::info!("CLN: Processing BOLT11 payment with hash {}", payment_hash); + PaymentIdentifier::PaymentHash(*payment_hash.as_ref()) + }, }; - return Some((request_look_up, (cln_client, last_pay_idx, cancel_token, is_active))); + let response = WaitPaymentResponse { + payment_identifier: request_lookup_id, + payment_amount: amount_sats.into(), + unit: CurrencyUnit::Sat, + payment_id: payment_hash.to_string() + }; + tracing::info!("CLN: Created WaitPaymentResponse with amount {} sats", amount_sats); + + break Some((response, (cln_client, last_pay_idx, cancel_token, is_active))); } Err(e) => { - tracing::warn!("Error fetching invoice: {e}"); - is_active.store(false, Ordering::SeqCst); - return None; + tracing::warn!("CLN: Error fetching invoice: {e}"); + tokio::time::sleep(Duration::from_secs(1)).await; + continue; } } } @@ -170,80 +261,199 @@ impl MintPayment for Cln { ) .boxed(); + tracing::info!("CLN: Successfully initialized invoice stream"); Ok(stream) } + #[instrument(skip_all)] async fn get_payment_quote( &self, - request: &str, unit: &CurrencyUnit, - options: Option, + options: OutgoingPaymentOptions, ) -> Result { - let bolt11 = Bolt11Invoice::from_str(request)?; + match options { + OutgoingPaymentOptions::Bolt11(bolt11_options) => { + // If we have specific amount options, use those + let amount_msat: Amount = if let Some(melt_options) = bolt11_options.melt_options { + match melt_options { + MeltOptions::Amountless { amountless } => { + let amount_msat = amountless.amount_msat; - let amount_msat = match options { - Some(amount) => amount.amount_msat(), - None => bolt11 - .amount_milli_satoshis() - .ok_or(Error::UnknownInvoiceAmount)? - .into(), - }; + if let Some(invoice_amount) = + bolt11_options.bolt11.amount_milli_satoshis() + { + if !invoice_amount == u64::from(amount_msat) { + return Err(payment::Error::AmountMismatch); + } + } + amount_msat + } + MeltOptions::Mpp { mpp } => mpp.amount, + } + } else { + // Fall back to invoice amount + bolt11_options + .bolt11 + .amount_milli_satoshis() + .ok_or(Error::UnknownInvoiceAmount)? + .into() + }; + // Convert to target unit + let amount = to_unit(amount_msat, &CurrencyUnit::Msat, unit)?; - let amount = to_unit(amount_msat, &CurrencyUnit::Msat, unit)?; + // Calculate fee + let relative_fee_reserve = + (self.fee_reserve.percent_fee_reserve * u64::from(amount) as f32) as u64; + let absolute_fee_reserve: u64 = self.fee_reserve.min_fee_reserve.into(); + let fee = max(relative_fee_reserve, absolute_fee_reserve); - let relative_fee_reserve = - (self.fee_reserve.percent_fee_reserve * u64::from(amount) as f32) as u64; - - let absolute_fee_reserve: u64 = self.fee_reserve.min_fee_reserve.into(); - - let fee = max(relative_fee_reserve, absolute_fee_reserve); - - Ok(PaymentQuoteResponse { - request_lookup_id: bolt11.payment_hash().to_string(), - amount, - unit: unit.clone(), - fee: fee.into(), - state: MeltQuoteState::Unpaid, - }) - } - - async fn make_payment( - &self, - melt_quote: mint::MeltQuote, - partial_amount: Option, - max_fee: Option, - ) -> Result { - let bolt11 = Bolt11Invoice::from_str(&melt_quote.request)?; - let pay_state = self - .check_outgoing_payment(&bolt11.payment_hash().to_string()) - .await?; - - match pay_state.status { - MeltQuoteState::Unpaid | MeltQuoteState::Unknown | MeltQuoteState::Failed => (), - MeltQuoteState::Paid => { - tracing::debug!("Melt attempted on invoice already paid"); - return Err(Self::Err::InvoiceAlreadyPaid); + Ok(PaymentQuoteResponse { + request_lookup_id: PaymentIdentifier::PaymentHash( + *bolt11_options.bolt11.payment_hash().as_ref(), + ), + amount, + fee: fee.into(), + state: MeltQuoteState::Unpaid, + options: None, + unit: unit.clone(), + }) } - MeltQuoteState::Pending => { - tracing::debug!("Melt attempted on invoice already pending"); - return Err(Self::Err::InvoicePaymentPending); + OutgoingPaymentOptions::Bolt12(bolt12_options) => { + let offer = bolt12_options.offer; + + let amount_msat: u64 = if let Some(amount) = bolt12_options.melt_options { + amount.amount_msat().into() + } else { + // Fall back to offer amount + let decode_response = self.decode_string(offer.to_string()).await?; + + decode_response + .offer_amount_msat + .ok_or(Error::UnknownInvoiceAmount)? + .msat() + }; + + // Convert to target unit + let amount = to_unit(amount_msat, &CurrencyUnit::Msat, unit)?; + + // Calculate fee + let relative_fee_reserve = + (self.fee_reserve.percent_fee_reserve * u64::from(amount) as f32) as u64; + let absolute_fee_reserve: u64 = self.fee_reserve.min_fee_reserve.into(); + let fee = max(relative_fee_reserve, absolute_fee_reserve); + + let cln_response; + { + // Fetch invoice from offer + let mut cln_client = self.cln_client().await?; + + cln_response = cln_client + .call_typed(&FetchinvoiceRequest { + amount_msat: Some(CLN_Amount::from_msat(amount_msat)), + payer_metadata: None, + payer_note: None, + quantity: None, + recurrence_counter: None, + recurrence_label: None, + recurrence_start: None, + timeout: None, + offer: offer.to_string(), + bip353: None, + }) + .await + .map_err(|err| { + tracing::error!("Could not fetch invoice for offer: {:?}", err); + Error::ClnRpc(err) + })?; + } + + let decode_response = self.decode_string(cln_response.invoice.clone()).await?; + + let options = payment::PaymentQuoteOptions::Bolt12 { + invoice: Some(cln_response.invoice.into()), + }; + + Ok(PaymentQuoteResponse { + request_lookup_id: PaymentIdentifier::Bolt12PaymentHash( + hex::decode( + decode_response + .invoice_payment_hash + .ok_or(Error::UnknownInvoice)?, + ) + .unwrap() + .try_into() + .map_err(|_| Error::InvalidHash)?, + ), + amount, + fee: fee.into(), + state: MeltQuoteState::Unpaid, + options: Some(options), + unit: unit.clone(), + }) } } + } - let amount_msat = partial_amount - .is_none() - .then(|| { - melt_quote - .msat_to_pay - .map(|a| CLN_Amount::from_msat(a.into())) - }) - .flatten(); + #[instrument(skip_all)] + async fn make_payment( + &self, + unit: &CurrencyUnit, + options: OutgoingPaymentOptions, + ) -> Result { + let max_fee_msat: Option; + let mut partial_amount: Option = None; + let mut amount_msat: Option = None; + + let invoice = match options { + OutgoingPaymentOptions::Bolt11(bolt11_options) => { + let payment_identifier = + PaymentIdentifier::PaymentHash(*bolt11_options.bolt11.payment_hash().as_ref()); + + self.check_outgoing_unpaided(&payment_identifier).await?; + + if let Some(melt_options) = bolt11_options.melt_options { + match melt_options { + MeltOptions::Mpp { mpp } => partial_amount = Some(mpp.amount.into()), + MeltOptions::Amountless { amountless } => { + amount_msat = Some(amountless.amount_msat.into()); + } + } + } + + max_fee_msat = bolt11_options.max_fee_amount.map(|a| a.into()); + + bolt11_options.bolt11.to_string() + } + OutgoingPaymentOptions::Bolt12(bolt12_options) => { + let bolt12_invoice = bolt12_options.invoice.ok_or(Error::UnknownInvoice)?; + let decode_response = self + .decode_string(String::from_utf8(bolt12_invoice.clone()).map_err(Error::Utf8)?) + .await?; + + let payment_identifier = PaymentIdentifier::Bolt12PaymentHash( + hex::decode( + decode_response + .invoice_payment_hash + .ok_or(Error::UnknownInvoice)?, + ) + .map_err(|e| Error::Bolt12(e.to_string()))? + .try_into() + .map_err(|_| Error::InvalidHash)?, + ); + + self.check_outgoing_unpaided(&payment_identifier).await?; + + max_fee_msat = bolt12_options.max_fee_amount.map(|a| a.into()); + String::from_utf8(bolt12_invoice).map_err(Error::Utf8)? + } + }; + + let mut cln_client = self.cln_client().await?; - let mut cln_client = cln_rpc::ClnRpc::new(&self.rpc_socket).await?; let cln_response = cln_client .call_typed(&PayRequest { - bolt11: melt_quote.request.to_string(), - amount_msat, + bolt11: invoice, + amount_msat: amount_msat.map(CLN_Amount::from_msat), label: None, riskfactor: None, maxfeepercent: None, @@ -252,22 +462,9 @@ impl MintPayment for Cln { exemptfee: None, localinvreqid: None, exclude: None, - maxfee: max_fee - .map(|a| { - let msat = to_unit(a, &melt_quote.unit, &CurrencyUnit::Msat)?; - Ok::(CLN_Amount::from_msat(msat.into())) - }) - .transpose()?, + maxfee: max_fee_msat.map(CLN_Amount::from_msat), description: None, - partial_msat: partial_amount - .map(|a| { - let msat = to_unit(a, &melt_quote.unit, &CurrencyUnit::Msat)?; - - Ok::(CLN_Amount::from_msat( - msat.into(), - )) - }) - .transpose()?, + partial_msat: partial_amount.map(CLN_Amount::from_msat), }) .await; @@ -279,16 +476,19 @@ impl MintPayment for Cln { PayStatus::FAILED => MeltQuoteState::Failed, }; + let payment_identifier = + PaymentIdentifier::PaymentHash(*pay_response.payment_hash.as_ref()); + MakePaymentResponse { payment_proof: Some(hex::encode(pay_response.payment_preimage.to_vec())), - payment_lookup_id: pay_response.payment_hash.to_string(), + payment_lookup_id: payment_identifier, status, total_spent: to_unit( pay_response.amount_sent_msat.msat(), &CurrencyUnit::Msat, - &melt_quote.unit, + unit, )?, - unit: melt_quote.unit, + unit: unit.clone(), } } Err(err) => { @@ -300,90 +500,206 @@ impl MintPayment for Cln { Ok(response) } + #[instrument(skip_all)] async fn create_incoming_payment_request( &self, - amount: Amount, unit: &CurrencyUnit, - description: String, - unix_expiry: Option, + options: IncomingPaymentOptions, ) -> Result { - let time_now = unix_time(); - - let mut cln_client = cln_rpc::ClnRpc::new(&self.rpc_socket).await?; - - let label = Uuid::new_v4().to_string(); - - let amount = to_unit(amount, unit, &CurrencyUnit::Msat)?; - let amount_msat = AmountOrAny::Amount(CLN_Amount::from_msat(amount.into())); - - let invoice_response = cln_client - .call_typed(&InvoiceRequest { - amount_msat, + match options { + IncomingPaymentOptions::Bolt11(Bolt11IncomingPaymentOptions { description, - label: label.clone(), - expiry: unix_expiry.map(|t| t - time_now), - fallbacks: None, - preimage: None, - cltv: None, - deschashonly: None, - exposeprivatechannels: None, - }) - .await - .map_err(Error::from)?; + amount, + unix_expiry, + }) => { + let time_now = unix_time(); - let request = Bolt11Invoice::from_str(&invoice_response.bolt11)?; - let expiry = request.expires_at().map(|t| t.as_secs()); - let payment_hash = request.payment_hash(); + let mut cln_client = self.cln_client().await?; - Ok(CreateIncomingPaymentResponse { - request_lookup_id: payment_hash.to_string(), - request: request.to_string(), - expiry, - }) + let label = Uuid::new_v4().to_string(); + + let amount = to_unit(amount, unit, &CurrencyUnit::Msat)?; + let amount_msat = AmountOrAny::Amount(CLN_Amount::from_msat(amount.into())); + + let invoice_response = cln_client + .call_typed(&InvoiceRequest { + amount_msat, + description: description.unwrap_or_default(), + label: label.clone(), + expiry: unix_expiry.map(|t| t - time_now), + fallbacks: None, + preimage: None, + cltv: None, + deschashonly: None, + exposeprivatechannels: None, + }) + .await + .map_err(Error::from)?; + + let request = Bolt11Invoice::from_str(&invoice_response.bolt11)?; + let expiry = request.expires_at().map(|t| t.as_secs()); + let payment_hash = request.payment_hash(); + + Ok(CreateIncomingPaymentResponse { + request_lookup_id: PaymentIdentifier::PaymentHash(*payment_hash.as_ref()), + request: request.to_string(), + expiry, + }) + } + IncomingPaymentOptions::Bolt12(bolt12_options) => { + let Bolt12IncomingPaymentOptions { + description, + amount, + unix_expiry, + } = *bolt12_options; + let mut cln_client = self.cln_client().await?; + + let label = Uuid::new_v4().to_string(); + + // Match like this until we change to option + let amount = match amount { + Some(amount) => { + let amount = to_unit(amount, unit, &CurrencyUnit::Msat)?; + + amount.to_string() + } + None => "any".to_string(), + }; + + // It seems that the only way to force cln to create a unique offer + // is to encode some random data in the offer + let issuer = Uuid::new_v4().to_string(); + + let offer_response = cln_client + .call_typed(&OfferRequest { + amount, + absolute_expiry: unix_expiry, + description: Some(description.unwrap_or_default()), + issuer: Some(issuer.to_string()), + label: Some(label.to_string()), + single_use: None, + quantity_max: None, + recurrence: None, + recurrence_base: None, + recurrence_limit: None, + recurrence_paywindow: None, + recurrence_start_any_period: None, + }) + .await + .map_err(Error::from)?; + + Ok(CreateIncomingPaymentResponse { + request_lookup_id: PaymentIdentifier::OfferId( + offer_response.offer_id.to_string(), + ), + request: offer_response.bolt12, + expiry: unix_expiry, + }) + } + } } + #[instrument(skip(self))] async fn check_incoming_payment_status( &self, - payment_hash: &str, - ) -> Result { - let mut cln_client = cln_rpc::ClnRpc::new(&self.rpc_socket).await?; + payment_identifier: &PaymentIdentifier, + ) -> Result, Self::Err> { + let mut cln_client = self.cln_client().await?; - let listinvoices_response = cln_client - .call_typed(&ListinvoicesRequest { - payment_hash: Some(payment_hash.to_string()), - label: None, - invstring: None, - offer_id: None, - index: None, - limit: None, - start: None, - }) - .await - .map_err(Error::from)?; - - let status = match listinvoices_response.invoices.first() { - Some(invoice_response) => cln_invoice_status_to_mint_state(invoice_response.status), - None => { - tracing::info!( - "Check invoice called on unknown look up id: {}", - payment_hash - ); - return Err(Error::WrongClnResponse.into()); + let listinvoices_response = match payment_identifier { + PaymentIdentifier::Label(label) => { + // Query by label + cln_client + .call_typed(&ListinvoicesRequest { + payment_hash: None, + label: Some(label.to_string()), + invstring: None, + offer_id: None, + index: None, + limit: None, + start: None, + }) + .await + .map_err(Error::from)? + } + PaymentIdentifier::OfferId(offer_id) => { + // Query by offer_id + cln_client + .call_typed(&ListinvoicesRequest { + payment_hash: None, + label: None, + invstring: None, + offer_id: Some(offer_id.to_string()), + index: None, + limit: None, + start: None, + }) + .await + .map_err(Error::from)? + } + PaymentIdentifier::PaymentHash(payment_hash) => { + // Query by payment_hash + cln_client + .call_typed(&ListinvoicesRequest { + payment_hash: Some(hex::encode(payment_hash)), + label: None, + invstring: None, + offer_id: None, + index: None, + limit: None, + start: None, + }) + .await + .map_err(Error::from)? + } + PaymentIdentifier::CustomId(_) => { + tracing::error!("Unsupported payment id for CLN"); + return Err(payment::Error::UnknownPaymentState); + } + PaymentIdentifier::Bolt12PaymentHash(_) => { + tracing::error!("Unsupported payment id for CLN"); + return Err(payment::Error::UnknownPaymentState); } }; - Ok(status) + Ok(listinvoices_response + .invoices + .iter() + .filter(|p| p.status == ListinvoicesInvoicesStatus::PAID) + .filter(|p| p.amount_msat.is_some()) // Filter out invoices without an amount + .map(|p| WaitPaymentResponse { + payment_identifier: payment_identifier.clone(), + payment_amount: p + .amount_msat + // Safe to expect since we filtered for Some + .expect("We have filter out those without amounts") + .msat() + .into(), + unit: CurrencyUnit::Msat, + payment_id: p.payment_hash.to_string(), + }) + .collect()) } + #[instrument(skip(self))] async fn check_outgoing_payment( &self, - payment_hash: &str, + payment_identifier: &PaymentIdentifier, ) -> Result { - let mut cln_client = cln_rpc::ClnRpc::new(&self.rpc_socket).await?; + let mut cln_client = self.cln_client().await?; + + let payment_hash = match payment_identifier { + PaymentIdentifier::PaymentHash(hash) => hash, + PaymentIdentifier::Bolt12PaymentHash(hash) => hash, + _ => { + tracing::error!("Unsupported identifier to check outgoing payment for cln."); + return Err(payment::Error::UnknownPaymentState); + } + }; let listpays_response = cln_client .call_typed(&ListpaysRequest { - payment_hash: Some(payment_hash.parse().map_err(|_| Error::InvalidHash)?), + payment_hash: Some(*Sha256::from_bytes_ref(payment_hash)), bolt11: None, status: None, start: None, @@ -398,7 +714,7 @@ impl MintPayment for Cln { let status = cln_pays_status_to_mint_state(pays_response.status); Ok(MakePaymentResponse { - payment_lookup_id: pays_response.payment_hash.to_string(), + payment_lookup_id: payment_identifier.clone(), payment_proof: pays_response.preimage.map(|p| hex::encode(p.to_vec())), status, total_spent: pays_response @@ -408,7 +724,7 @@ impl MintPayment for Cln { }) } None => Ok(MakePaymentResponse { - payment_lookup_id: payment_hash.to_string(), + payment_lookup_id: payment_identifier.clone(), payment_proof: None, status: MeltQuoteState::Unknown, total_spent: Amount::ZERO, @@ -419,9 +735,13 @@ impl MintPayment for Cln { } impl Cln { + async fn cln_client(&self) -> Result { + Ok(cln_rpc::ClnRpc::new(&self.rpc_socket).await?) + } + /// Get last pay index for cln async fn get_last_pay_index(&self) -> Result, Error> { - let mut cln_client = cln_rpc::ClnRpc::new(&self.rpc_socket).await?; + let mut cln_client = self.cln_client().await?; let listinvoices_response = cln_client .call_typed(&ListinvoicesRequest { index: None, @@ -440,13 +760,40 @@ impl Cln { None => Ok(None), } } -} -fn cln_invoice_status_to_mint_state(status: ListinvoicesInvoicesStatus) -> MintQuoteState { - match status { - ListinvoicesInvoicesStatus::UNPAID => MintQuoteState::Unpaid, - ListinvoicesInvoicesStatus::PAID => MintQuoteState::Paid, - ListinvoicesInvoicesStatus::EXPIRED => MintQuoteState::Unpaid, + /// Decode string + #[instrument(skip(self))] + async fn decode_string(&self, string: String) -> Result { + let mut cln_client = self.cln_client().await?; + + cln_client + .call_typed(&DecodeRequest { string }) + .await + .map_err(|err| { + tracing::error!("Could not fetch invoice for offer: {:?}", err); + Error::ClnRpc(err) + }) + } + + /// Checks that outgoing payment is not already paid + #[instrument(skip(self))] + async fn check_outgoing_unpaided( + &self, + payment_identifier: &PaymentIdentifier, + ) -> Result<(), payment::Error> { + let pay_state = self.check_outgoing_payment(payment_identifier).await?; + + match pay_state.status { + MeltQuoteState::Unpaid | MeltQuoteState::Unknown | MeltQuoteState::Failed => Ok(()), + MeltQuoteState::Paid => { + tracing::debug!("Melt attempted on invoice already paid"); + Err(payment::Error::InvoiceAlreadyPaid) + } + MeltQuoteState::Pending => { + tracing::debug!("Melt attempted on invoice already pending"); + Err(payment::Error::InvoicePaymentPending) + } + } } } @@ -460,23 +807,57 @@ fn cln_pays_status_to_mint_state(status: ListpaysPaysStatus) -> MeltQuoteState { async fn fetch_invoice_by_payment_hash( cln_client: &mut cln_rpc::ClnRpc, - payment_hash: &str, + payment_hash: &Hash, ) -> Result, Error> { - match cln_client - .call_typed(&ListinvoicesRequest { - payment_hash: Some(payment_hash.to_string()), - index: None, - invstring: None, - label: None, - limit: None, - offer_id: None, - start: None, - }) - .await - { - Ok(invoice_response) => Ok(invoice_response.invoices.first().cloned()), + tracing::debug!("Fetching invoice by payment hash: {}", payment_hash); + + let payment_hash_str = payment_hash.to_string(); + tracing::debug!("Payment hash string: {}", payment_hash_str); + + let request = ListinvoicesRequest { + payment_hash: Some(payment_hash_str), + index: None, + invstring: None, + label: None, + limit: None, + offer_id: None, + start: None, + }; + tracing::debug!("Created ListinvoicesRequest"); + + match cln_client.call_typed(&request).await { + Ok(invoice_response) => { + let invoice_count = invoice_response.invoices.len(); + tracing::debug!( + "Received {} invoices for payment hash {}", + invoice_count, + payment_hash + ); + + if invoice_count > 0 { + let first_invoice = invoice_response.invoices.first().cloned(); + if let Some(invoice) = &first_invoice { + tracing::debug!("Found invoice with payment hash {}", payment_hash); + tracing::debug!( + "Invoice details - local_offer_id: {:?}, status: {:?}", + invoice.local_offer_id, + invoice.status + ); + } else { + tracing::warn!("No invoice found with payment hash {}", payment_hash); + } + Ok(first_invoice) + } else { + tracing::warn!("No invoices returned for payment hash {}", payment_hash); + Ok(None) + } + } Err(e) => { - tracing::warn!("Error fetching invoice: {e}"); + tracing::error!( + "Error fetching invoice by payment hash {}: {}", + payment_hash, + e + ); Err(Error::from(e)) } } diff --git a/crates/cdk-common/Cargo.toml b/crates/cdk-common/Cargo.toml index 2c9ce391..a6c1466a 100644 --- a/crates/cdk-common/Cargo.toml +++ b/crates/cdk-common/Cargo.toml @@ -27,6 +27,7 @@ cbor-diag.workspace = true ciborium.workspace = true serde.workspace = true lightning-invoice.workspace = true +lightning.workspace = true thiserror.workspace = true tracing.workspace = true url.workspace = true diff --git a/crates/cdk-common/src/database/mint/mod.rs b/crates/cdk-common/src/database/mint/mod.rs index 95eb3db0..7ba65304 100644 --- a/crates/cdk-common/src/database/mint/mod.rs +++ b/crates/cdk-common/src/database/mint/mod.rs @@ -3,16 +3,16 @@ use std::collections::HashMap; use async_trait::async_trait; -use cashu::MintInfo; +use cashu::{Amount, MintInfo}; use uuid::Uuid; use super::Error; use crate::common::QuoteTTL; use crate::mint::{self, MintKeySetInfo, MintQuote as MintMintQuote}; use crate::nuts::{ - BlindSignature, CurrencyUnit, Id, MeltQuoteState, MintQuoteState, Proof, Proofs, PublicKey, - State, + BlindSignature, CurrencyUnit, Id, MeltQuoteState, Proof, Proofs, PublicKey, State, }; +use crate::payment::PaymentIdentifier; #[cfg(feature = "auth")] mod auth; @@ -67,13 +67,20 @@ pub trait QuotesTransaction<'a> { async fn get_mint_quote(&mut self, quote_id: &Uuid) -> Result, Self::Err>; /// Add [`MintMintQuote`] - async fn add_or_replace_mint_quote(&mut self, quote: MintMintQuote) -> Result<(), Self::Err>; - /// Update state of [`MintMintQuote`] - async fn update_mint_quote_state( + async fn add_mint_quote(&mut self, quote: MintMintQuote) -> Result<(), Self::Err>; + /// Increment amount paid [`MintMintQuote`] + async fn increment_mint_quote_amount_paid( &mut self, quote_id: &Uuid, - state: MintQuoteState, - ) -> Result; + amount_paid: Amount, + payment_id: String, + ) -> Result; + /// Increment amount paid [`MintMintQuote`] + async fn increment_mint_quote_amount_issued( + &mut self, + quote_id: &Uuid, + amount_issued: Amount, + ) -> Result; /// Remove [`MintMintQuote`] async fn remove_mint_quote(&mut self, quote_id: &Uuid) -> Result<(), Self::Err>; /// Get [`mint::MeltQuote`] and lock it for update in this transaction @@ -88,7 +95,7 @@ pub trait QuotesTransaction<'a> { async fn update_melt_quote_request_lookup_id( &mut self, quote_id: &Uuid, - new_request_lookup_id: &str, + new_request_lookup_id: &PaymentIdentifier, ) -> Result<(), Self::Err>; /// Update [`mint::MeltQuote`] state @@ -98,6 +105,7 @@ pub trait QuotesTransaction<'a> { &mut self, quote_id: &Uuid, new_state: MeltQuoteState, + payment_proof: Option, ) -> Result<(MeltQuoteState, mint::MeltQuote), Self::Err>; /// Remove [`mint::MeltQuote`] async fn remove_melt_quote(&mut self, quote_id: &Uuid) -> Result<(), Self::Err>; @@ -106,6 +114,12 @@ pub trait QuotesTransaction<'a> { &mut self, request: &str, ) -> Result, Self::Err>; + + /// Get all [`MintMintQuote`]s + async fn get_mint_quote_by_request_lookup_id( + &mut self, + request_lookup_id: &PaymentIdentifier, + ) -> Result, Self::Err>; } /// Mint Quote Database trait @@ -125,15 +139,10 @@ pub trait QuotesDatabase { /// Get all [`MintMintQuote`]s async fn get_mint_quote_by_request_lookup_id( &self, - request_lookup_id: &str, + request_lookup_id: &PaymentIdentifier, ) -> Result, Self::Err>; /// Get Mint Quotes async fn get_mint_quotes(&self) -> Result, Self::Err>; - /// Get Mint Quotes with state - async fn get_mint_quotes_with_state( - &self, - state: MintQuoteState, - ) -> Result, Self::Err>; /// Get [`mint::MeltQuote`] async fn get_melt_quote(&self, quote_id: &Uuid) -> Result, Self::Err>; /// Get all [`mint::MeltQuote`]s diff --git a/crates/cdk-common/src/database/mod.rs b/crates/cdk-common/src/database/mod.rs index 8fad4132..195e210b 100644 --- a/crates/cdk-common/src/database/mod.rs +++ b/crates/cdk-common/src/database/mod.rs @@ -29,6 +29,9 @@ pub enum Error { /// Duplicate entry #[error("Duplicate entry")] Duplicate, + /// Amount overflow + #[error("Amount overflow")] + AmountOverflow, /// DHKE error #[error(transparent)] diff --git a/crates/cdk-common/src/error.rs b/crates/cdk-common/src/error.rs index d0264401..84275118 100644 --- a/crates/cdk-common/src/error.rs +++ b/crates/cdk-common/src/error.rs @@ -91,6 +91,24 @@ pub enum Error { /// Multi-Part Payment not supported for unit and method #[error("Amountless invoices are not supported for unit `{0}` and method `{1}`")] AmountlessInvoiceNotSupported(CurrencyUnit, PaymentMethod), + /// Duplicate Payment id + #[error("Payment id seen for mint")] + DuplicatePaymentId, + /// Pubkey required + #[error("Pubkey required")] + PubkeyRequired, + /// Invalid payment method + #[error("Invalid payment method")] + InvalidPaymentMethod, + /// Amount undefined + #[error("Amount undefined")] + AmountUndefined, + /// Unsupported payment method + #[error("Payment method unsupported")] + UnsupportedPaymentMethod, + /// Could not parse bolt12 + #[error("Could not parse bolt12")] + Bolt12parse, /// Internal Error - Send error #[error("Internal send error: {0}")] diff --git a/crates/cdk-common/src/lib.rs b/crates/cdk-common/src/lib.rs index 3dec0a8d..fcd7bfc9 100644 --- a/crates/cdk-common/src/lib.rs +++ b/crates/cdk-common/src/lib.rs @@ -12,6 +12,8 @@ pub mod common; pub mod database; pub mod error; #[cfg(feature = "mint")] +pub mod melt; +#[cfg(feature = "mint")] pub mod mint; #[cfg(feature = "mint")] pub mod payment; diff --git a/crates/cdk-common/src/melt.rs b/crates/cdk-common/src/melt.rs new file mode 100644 index 00000000..d744b388 --- /dev/null +++ b/crates/cdk-common/src/melt.rs @@ -0,0 +1,26 @@ +//! Melt types +use cashu::{MeltQuoteBolt11Request, MeltQuoteBolt12Request}; + +/// Melt quote request enum for different types of quotes +/// +/// This enum represents the different types of melt quote requests +/// that can be made, either BOLT11 or BOLT12. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MeltQuoteRequest { + /// Lightning Network BOLT11 invoice request + Bolt11(MeltQuoteBolt11Request), + /// Lightning Network BOLT12 offer request + Bolt12(MeltQuoteBolt12Request), +} + +impl From for MeltQuoteRequest { + fn from(request: MeltQuoteBolt11Request) -> Self { + MeltQuoteRequest::Bolt11(request) + } +} + +impl From for MeltQuoteRequest { + fn from(request: MeltQuoteBolt12Request) -> Self { + MeltQuoteRequest::Bolt12(request) + } +} diff --git a/crates/cdk-common/src/mint.rs b/crates/cdk-common/src/mint.rs index 089c3654..49b94d5b 100644 --- a/crates/cdk-common/src/mint.rs +++ b/crates/cdk-common/src/mint.rs @@ -2,11 +2,17 @@ use bitcoin::bip32::DerivationPath; use cashu::util::unix_time; -use cashu::{MeltQuoteBolt11Response, MintQuoteBolt11Response}; +use cashu::{ + Bolt11Invoice, MeltOptions, MeltQuoteBolt11Response, MintQuoteBolt11Response, + MintQuoteBolt12Response, PaymentMethod, +}; +use lightning::offers::offer::Offer; use serde::{Deserialize, Serialize}; +use tracing::instrument; use uuid::Uuid; use crate::nuts::{MeltQuoteState, MintQuoteState}; +use crate::payment::PaymentIdentifier; use crate::{Amount, CurrencyUnit, Id, KeySetInfo, PublicKey}; /// Mint Quote Info @@ -15,54 +21,209 @@ pub struct MintQuote { /// Quote id pub id: Uuid, /// Amount of quote - pub amount: Amount, + pub amount: Option, /// Unit of quote pub unit: CurrencyUnit, /// Quote payment request e.g. bolt11 pub request: String, - /// Quote state - pub state: MintQuoteState, /// Expiration time of quote pub expiry: u64, /// Value used by ln backend to look up state of request - pub request_lookup_id: String, + pub request_lookup_id: PaymentIdentifier, /// Pubkey pub pubkey: Option, /// Unix time quote was created #[serde(default)] pub created_time: u64, - /// Unix time quote was paid - pub paid_time: Option, - /// Unix time quote was issued - pub issued_time: Option, + /// Amount paid + #[serde(default)] + amount_paid: Amount, + /// Amount issued + #[serde(default)] + amount_issued: Amount, + /// Payment of payment(s) that filled quote + #[serde(default)] + pub payments: Vec, + /// Payment Method + #[serde(default)] + pub payment_method: PaymentMethod, + /// Payment of payment(s) that filled quote + #[serde(default)] + pub issuance: Vec, } impl MintQuote { /// Create new [`MintQuote`] + #[allow(clippy::too_many_arguments)] pub fn new( + id: Option, request: String, unit: CurrencyUnit, - amount: Amount, + amount: Option, expiry: u64, - request_lookup_id: String, + request_lookup_id: PaymentIdentifier, pubkey: Option, + amount_paid: Amount, + amount_issued: Amount, + payment_method: PaymentMethod, + created_time: u64, + payments: Vec, + issuance: Vec, ) -> Self { - let id = Uuid::new_v4(); + let id = id.unwrap_or(Uuid::new_v4()); Self { id, amount, unit, request, - state: MintQuoteState::Unpaid, expiry, request_lookup_id, pubkey, - created_time: unix_time(), - paid_time: None, - issued_time: None, + created_time, + amount_paid, + amount_issued, + payment_method, + payments, + issuance, } } + + /// Increment the amount paid on the mint quote by a given amount + #[instrument(skip(self))] + pub fn increment_amount_paid( + &mut self, + additional_amount: Amount, + ) -> Result { + self.amount_paid = self + .amount_paid + .checked_add(additional_amount) + .ok_or(crate::Error::AmountOverflow)?; + Ok(self.amount_paid) + } + + /// Amount paid + #[instrument(skip(self))] + pub fn amount_paid(&self) -> Amount { + self.amount_paid + } + + /// Increment the amount issued on the mint quote by a given amount + #[instrument(skip(self))] + pub fn increment_amount_issued( + &mut self, + additional_amount: Amount, + ) -> Result { + self.amount_issued = self + .amount_issued + .checked_add(additional_amount) + .ok_or(crate::Error::AmountOverflow)?; + Ok(self.amount_issued) + } + + /// Amount issued + #[instrument(skip(self))] + pub fn amount_issued(&self) -> Amount { + self.amount_issued + } + + /// Get state of mint quote + #[instrument(skip(self))] + pub fn state(&self) -> MintQuoteState { + self.compute_quote_state() + } + + /// Existing payment ids of a mint quote + pub fn payment_ids(&self) -> Vec<&String> { + self.payments.iter().map(|a| &a.payment_id).collect() + } + + /// Add a payment ID to the list of payment IDs + /// + /// Returns an error if the payment ID is already in the list + #[instrument(skip(self))] + pub fn add_payment( + &mut self, + amount: Amount, + payment_id: String, + time: u64, + ) -> Result<(), crate::Error> { + let payment_ids = self.payment_ids(); + if payment_ids.contains(&&payment_id) { + return Err(crate::Error::DuplicatePaymentId); + } + + let payment = IncomingPayment::new(amount, payment_id, time); + + self.payments.push(payment); + Ok(()) + } + + /// Compute quote state + #[instrument(skip(self))] + fn compute_quote_state(&self) -> MintQuoteState { + if self.amount_paid == Amount::ZERO && self.amount_issued == Amount::ZERO { + return MintQuoteState::Unpaid; + } + + match self.amount_paid.cmp(&self.amount_issued) { + std::cmp::Ordering::Less => { + // self.amount_paid is less than other (amount issued) + // Handle case where paid amount is insufficient + tracing::error!("We should not have issued more then has been paid"); + MintQuoteState::Issued + } + std::cmp::Ordering::Equal => { + // We do this extra check for backwards compatibility for quotes where amount paid/issed was not tracked + // self.amount_paid equals other (amount issued) + // Handle case where paid amount exactly matches + MintQuoteState::Issued + } + std::cmp::Ordering::Greater => { + // self.amount_paid is greater than other (amount issued) + // Handle case where paid amount exceeds required amount + MintQuoteState::Paid + } + } + } +} + +/// Mint Payments +#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] +pub struct IncomingPayment { + /// Amount + pub amount: Amount, + /// Pyament unix time + pub time: u64, + /// Payment id + pub payment_id: String, +} + +impl IncomingPayment { + /// New [`IncomingPayment`] + pub fn new(amount: Amount, payment_id: String, time: u64) -> Self { + Self { + payment_id, + time, + amount, + } + } +} + +/// Informattion about issued quote +#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] +pub struct Issuance { + /// Amount + pub amount: Amount, + /// Time + pub time: u64, +} + +impl Issuance { + /// Create new [`Issuance`] + pub fn new(amount: Amount, time: u64) -> Self { + Self { amount, time } + } } /// Melt Quote Info @@ -75,7 +236,7 @@ pub struct MeltQuote { /// Quote amount pub amount: Amount, /// Quote Payment request e.g. bolt11 - pub request: String, + pub request: MeltPaymentRequest, /// Quote fee reserve pub fee_reserve: Amount, /// Quote state @@ -85,28 +246,33 @@ pub struct MeltQuote { /// Payment preimage pub payment_preimage: Option, /// Value used by ln backend to look up state of request - pub request_lookup_id: String, - /// Msat to pay + pub request_lookup_id: PaymentIdentifier, + /// Payment options /// - /// Used for an amountless invoice - pub msat_to_pay: Option, + /// Used for amountless invoices and MPP payments + pub options: Option, /// Unix time quote was created #[serde(default)] pub created_time: u64, /// Unix time quote was paid pub paid_time: Option, + /// Payment method + #[serde(default)] + pub payment_method: PaymentMethod, } impl MeltQuote { /// Create new [`MeltQuote`] + #[allow(clippy::too_many_arguments)] pub fn new( - request: String, + request: MeltPaymentRequest, unit: CurrencyUnit, amount: Amount, fee_reserve: Amount, expiry: u64, - request_lookup_id: String, - msat_to_pay: Option, + request_lookup_id: PaymentIdentifier, + options: Option, + payment_method: PaymentMethod, ) -> Self { let id = Uuid::new_v4(); @@ -120,9 +286,10 @@ impl MeltQuote { expiry, payment_preimage: None, request_lookup_id, - msat_to_pay, + options, created_time: unix_time(), paid_time: None, + payment_method, } } } @@ -173,16 +340,51 @@ impl From for MintQuoteBolt11Response { fn from(mint_quote: crate::mint::MintQuote) -> MintQuoteBolt11Response { MintQuoteBolt11Response { quote: mint_quote.id, + state: mint_quote.state(), request: mint_quote.request, - state: mint_quote.state, expiry: Some(mint_quote.expiry), pubkey: mint_quote.pubkey, - amount: Some(mint_quote.amount), + amount: mint_quote.amount, unit: Some(mint_quote.unit.clone()), } } } +impl From for MintQuoteBolt11Response { + fn from(quote: MintQuote) -> Self { + let quote: MintQuoteBolt11Response = quote.into(); + + quote.into() + } +} + +impl TryFrom for MintQuoteBolt12Response { + type Error = crate::Error; + + fn try_from(mint_quote: crate::mint::MintQuote) -> Result { + Ok(MintQuoteBolt12Response { + quote: mint_quote.id, + request: mint_quote.request, + expiry: Some(mint_quote.expiry), + amount_paid: mint_quote.amount_paid, + amount_issued: mint_quote.amount_issued, + pubkey: mint_quote.pubkey.ok_or(crate::Error::PubkeyRequired)?, + amount: mint_quote.amount, + unit: mint_quote.unit, + }) + } +} + +impl TryFrom for MintQuoteBolt12Response { + type Error = crate::Error; + + fn try_from(quote: MintQuote) -> Result { + let quote: MintQuoteBolt12Response = quote.try_into()?; + + Ok(quote.into()) + } +} + impl From<&MeltQuote> for MeltQuoteBolt11Response { fn from(melt_quote: &MeltQuote) -> MeltQuoteBolt11Response { MeltQuoteBolt11Response { @@ -212,8 +414,61 @@ impl From for MeltQuoteBolt11Response { expiry: melt_quote.expiry, payment_preimage: melt_quote.payment_preimage, change: None, - request: Some(melt_quote.request.clone()), + request: Some(melt_quote.request.to_string()), unit: Some(melt_quote.unit.clone()), } } } + +/// Payment request +#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] +pub enum MeltPaymentRequest { + /// Bolt11 Payment + Bolt11 { + /// Bolt11 invoice + bolt11: Bolt11Invoice, + }, + /// Bolt12 Payment + Bolt12 { + /// Offer + #[serde(with = "offer_serde")] + offer: Box, + /// Invoice + invoice: Option>, + }, +} + +impl std::fmt::Display for MeltPaymentRequest { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + MeltPaymentRequest::Bolt11 { bolt11 } => write!(f, "{bolt11}"), + MeltPaymentRequest::Bolt12 { offer, invoice: _ } => write!(f, "{offer}"), + } + } +} + +mod offer_serde { + use std::str::FromStr; + + use serde::{self, Deserialize, Deserializer, Serializer}; + + use super::Offer; + + pub fn serialize(offer: &Offer, serializer: S) -> Result + where + S: Serializer, + { + let s = offer.to_string(); + serializer.serialize_str(&s) + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + Ok(Box::new(Offer::from_str(&s).map_err(|_| { + serde::de::Error::custom("Invalid Bolt12 Offer") + })?)) + } +} diff --git a/crates/cdk-common/src/payment.rs b/crates/cdk-common/src/payment.rs index fabbabac..923c0f05 100644 --- a/crates/cdk-common/src/payment.rs +++ b/crates/cdk-common/src/payment.rs @@ -1,17 +1,21 @@ //! CDK Mint Lightning +use std::convert::Infallible; use std::pin::Pin; use async_trait::async_trait; -use cashu::MeltOptions; +use cashu::util::hex; +use cashu::{Bolt11Invoice, MeltOptions}; use futures::Stream; +use lightning::offers::offer::Offer; use lightning_invoice::ParseOrSemanticError; use serde::{Deserialize, Serialize}; use serde_json::Value; use thiserror::Error; -use crate::nuts::{CurrencyUnit, MeltQuoteState, MintQuoteState}; -use crate::{mint, Amount}; +use crate::mint::MeltPaymentRequest; +use crate::nuts::{CurrencyUnit, MeltQuoteState}; +use crate::Amount; /// CDK Lightning Error #[derive(Debug, Error)] @@ -31,6 +35,9 @@ pub enum Error { /// Payment state is unknown #[error("Payment state is unknown")] UnknownPaymentState, + /// Amount mismatch + #[error("Amount is not what is expected")] + AmountMismatch, /// Lightning Error #[error(transparent)] Lightning(Box), @@ -55,11 +62,186 @@ pub enum Error { /// NUT23 Error #[error(transparent)] NUT23(#[from] crate::nuts::nut23::Error), + /// Hex error + #[error("Hex error")] + Hex(#[from] hex::Error), + /// Invalid hash + #[error("Invalid hash")] + InvalidHash, /// Custom #[error("`{0}`")] Custom(String), } +impl From for Error { + fn from(_: Infallible) -> Self { + unreachable!("Infallible cannot be constructed") + } +} + +/// Payment identifier types +#[derive(Debug, Clone, Hash, PartialEq, Eq, Deserialize, Serialize)] +#[serde(tag = "type", content = "value")] +pub enum PaymentIdentifier { + /// Label identifier + Label(String), + /// Offer ID identifier + OfferId(String), + /// Payment hash identifier + PaymentHash([u8; 32]), + /// Bolt12 payment hash + Bolt12PaymentHash([u8; 32]), + /// Custom Payment ID + CustomId(String), +} + +impl PaymentIdentifier { + /// Create new [`PaymentIdentifier`] + pub fn new(kind: &str, identifier: &str) -> Result { + match kind.to_lowercase().as_str() { + "label" => Ok(Self::Label(identifier.to_string())), + "offer_id" => Ok(Self::OfferId(identifier.to_string())), + "payment_hash" => Ok(Self::PaymentHash( + hex::decode(identifier)? + .try_into() + .map_err(|_| Error::InvalidHash)?, + )), + "bolt12_payment_hash" => Ok(Self::Bolt12PaymentHash( + hex::decode(identifier)? + .try_into() + .map_err(|_| Error::InvalidHash)?, + )), + "custom" => Ok(Self::CustomId(identifier.to_string())), + _ => Err(Error::UnsupportedPaymentOption), + } + } + + /// Payment id kind + pub fn kind(&self) -> String { + match self { + Self::Label(_) => "label".to_string(), + Self::OfferId(_) => "offer_id".to_string(), + Self::PaymentHash(_) => "payment_hash".to_string(), + Self::Bolt12PaymentHash(_) => "bolt12_payment_hash".to_string(), + Self::CustomId(_) => "custom".to_string(), + } + } +} + +impl std::fmt::Display for PaymentIdentifier { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Label(l) => write!(f, "{l}"), + Self::OfferId(o) => write!(f, "{o}"), + Self::PaymentHash(h) => write!(f, "{}", hex::encode(h)), + Self::Bolt12PaymentHash(h) => write!(f, "{}", hex::encode(h)), + Self::CustomId(c) => write!(f, "{c}"), + } + } +} + +/// Options for creating a BOLT11 incoming payment request +#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)] +pub struct Bolt11IncomingPaymentOptions { + /// Optional description for the payment request + pub description: Option, + /// Amount for the payment request in sats + pub amount: Amount, + /// Optional expiry time as Unix timestamp in seconds + pub unix_expiry: Option, +} + +/// Options for creating a BOLT12 incoming payment request +#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)] +pub struct Bolt12IncomingPaymentOptions { + /// Optional description for the payment request + pub description: Option, + /// Optional amount for the payment request in sats + pub amount: Option, + /// Optional expiry time as Unix timestamp in seconds + pub unix_expiry: Option, +} + +/// Options for creating an incoming payment request +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum IncomingPaymentOptions { + /// BOLT11 payment request options + Bolt11(Bolt11IncomingPaymentOptions), + /// BOLT12 payment request options + Bolt12(Box), +} + +/// Options for BOLT11 outgoing payments +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Bolt11OutgoingPaymentOptions { + /// Bolt11 + pub bolt11: Bolt11Invoice, + /// Maximum fee amount allowed for the payment + pub max_fee_amount: Option, + /// Optional timeout in seconds + pub timeout_secs: Option, + /// Melt options + pub melt_options: Option, +} + +/// Options for BOLT12 outgoing payments +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Bolt12OutgoingPaymentOptions { + /// Offer + pub offer: Offer, + /// Maximum fee amount allowed for the payment + pub max_fee_amount: Option, + /// Optional timeout in seconds + pub timeout_secs: Option, + /// Bolt12 Invoice + pub invoice: Option>, + /// Melt options + pub melt_options: Option, +} + +/// Options for creating an outgoing payment +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum OutgoingPaymentOptions { + /// BOLT11 payment options + Bolt11(Box), + /// BOLT12 payment options + Bolt12(Box), +} + +impl TryFrom for OutgoingPaymentOptions { + type Error = Error; + + fn try_from(melt_quote: crate::mint::MeltQuote) -> Result { + match melt_quote.request { + MeltPaymentRequest::Bolt11 { bolt11 } => Ok(OutgoingPaymentOptions::Bolt11(Box::new( + Bolt11OutgoingPaymentOptions { + max_fee_amount: Some(melt_quote.fee_reserve), + timeout_secs: None, + bolt11, + melt_options: melt_quote.options, + }, + ))), + MeltPaymentRequest::Bolt12 { offer, invoice } => { + let melt_options = match melt_quote.options { + None => None, + Some(MeltOptions::Mpp { mpp: _ }) => return Err(Error::UnsupportedUnit), + Some(options) => Some(options), + }; + + Ok(OutgoingPaymentOptions::Bolt12(Box::new( + Bolt12OutgoingPaymentOptions { + max_fee_amount: Some(melt_quote.fee_reserve), + timeout_secs: None, + offer: *offer, + invoice, + melt_options, + }, + ))) + } + } + } +} + /// Mint payment trait #[async_trait] pub trait MintPayment { @@ -72,34 +254,30 @@ pub trait MintPayment { /// Create a new invoice async fn create_incoming_payment_request( &self, - amount: Amount, unit: &CurrencyUnit, - description: String, - unix_expiry: Option, + options: IncomingPaymentOptions, ) -> Result; /// Get payment quote /// Used to get fee and amount required for a payment request async fn get_payment_quote( &self, - request: &str, unit: &CurrencyUnit, - options: Option, + options: OutgoingPaymentOptions, ) -> Result; /// Pay request async fn make_payment( &self, - melt_quote: mint::MeltQuote, - partial_amount: Option, - max_fee_amount: Option, + unit: &CurrencyUnit, + options: OutgoingPaymentOptions, ) -> Result; /// Listen for invoices to be paid to the mint /// Returns a stream of request_lookup_id once invoices are paid async fn wait_any_incoming_payment( &self, - ) -> Result + Send>>, Self::Err>; + ) -> Result + Send>>, Self::Err>; /// Is wait invoice active fn is_wait_invoice_active(&self) -> bool; @@ -110,21 +288,36 @@ pub trait MintPayment { /// Check the status of an incoming payment async fn check_incoming_payment_status( &self, - request_lookup_id: &str, - ) -> Result; + payment_identifier: &PaymentIdentifier, + ) -> Result, Self::Err>; /// Check the status of an outgoing payment async fn check_outgoing_payment( &self, - request_lookup_id: &str, + payment_identifier: &PaymentIdentifier, ) -> Result; } +/// Wait any invoice response +#[derive(Debug, Clone, Hash, Serialize, Deserialize)] +pub struct WaitPaymentResponse { + /// Request look up id + /// Id that relates the quote and payment request + pub payment_identifier: PaymentIdentifier, + /// Payment amount + pub payment_amount: Amount, + /// Unit + pub unit: CurrencyUnit, + /// Unique id of payment + // Payment hash + pub payment_id: String, +} + /// Create incoming payment response #[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] pub struct CreateIncomingPaymentResponse { /// Id that is used to look up the payment from the ln backend - pub request_lookup_id: String, + pub request_lookup_id: PaymentIdentifier, /// Payment request pub request: String, /// Unix Expiry of Invoice @@ -135,7 +328,7 @@ pub struct CreateIncomingPaymentResponse { #[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] pub struct MakePaymentResponse { /// Payment hash - pub payment_lookup_id: String, + pub payment_lookup_id: PaymentIdentifier, /// Payment proof pub payment_proof: Option, /// Status @@ -150,7 +343,7 @@ pub struct MakePaymentResponse { #[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] pub struct PaymentQuoteResponse { /// Request look up id - pub request_lookup_id: String, + pub request_lookup_id: PaymentIdentifier, /// Amount pub amount: Amount, /// Fee required for melt @@ -159,6 +352,18 @@ pub struct PaymentQuoteResponse { pub unit: CurrencyUnit, /// Status pub state: MeltQuoteState, + /// Payment Quote Options + pub options: Option, +} + +/// Payment quote options +#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] +pub enum PaymentQuoteOptions { + /// Bolt12 payment options + Bolt12 { + /// Bolt12 invoice + invoice: Option>, + }, } /// Ln backend settings @@ -172,6 +377,8 @@ pub struct Bolt11Settings { pub invoice_description: bool, /// Paying amountless invoices supported pub amountless: bool, + /// Bolt12 supported + pub bolt12: bool, } impl TryFrom for Value { diff --git a/crates/cdk-common/src/subscription.rs b/crates/cdk-common/src/subscription.rs index ba14de1b..50c6cbe7 100644 --- a/crates/cdk-common/src/subscription.rs +++ b/crates/cdk-common/src/subscription.rs @@ -81,6 +81,9 @@ impl Indexable for NotificationPayload { NotificationPayload::MintQuoteBolt11Response(mint_quote) => { vec![Index::from(Notification::MintQuoteBolt11(mint_quote.quote))] } + NotificationPayload::MintQuoteBolt12Response(mint_quote) => { + vec![Index::from(Notification::MintQuoteBolt12(mint_quote.quote))] + } } } } diff --git a/crates/cdk-common/src/wallet.rs b/crates/cdk-common/src/wallet.rs index 5634d6de..d9aca538 100644 --- a/crates/cdk-common/src/wallet.rs +++ b/crates/cdk-common/src/wallet.rs @@ -6,7 +6,7 @@ use std::str::FromStr; use bitcoin::hashes::{sha256, Hash, HashEngine}; use cashu::util::hex; -use cashu::{nut00, Proofs, PublicKey}; +use cashu::{nut00, PaymentMethod, Proofs, PublicKey}; use serde::{Deserialize, Serialize}; use crate::mint_url::MintUrl; @@ -42,8 +42,11 @@ pub struct MintQuote { pub id: String, /// Mint Url pub mint_url: MintUrl, + /// Payment method + #[serde(default)] + pub payment_method: PaymentMethod, /// Amount of quote - pub amount: Amount, + pub amount: Option, /// Unit of quote pub unit: CurrencyUnit, /// Quote payment request e.g. bolt11 @@ -54,6 +57,12 @@ pub struct MintQuote { pub expiry: u64, /// Secretkey for signing mint quotes [NUT-20] pub secret_key: Option, + /// Amount minted + #[serde(default)] + pub amount_issued: Amount, + /// Amount paid to the mint for the quote + #[serde(default)] + pub amount_paid: Amount, } /// Melt Quote Info @@ -77,6 +86,62 @@ pub struct MeltQuote { pub payment_preimage: Option, } +impl MintQuote { + /// Create a new MintQuote + #[allow(clippy::too_many_arguments)] + pub fn new( + id: String, + mint_url: MintUrl, + payment_method: PaymentMethod, + amount: Option, + unit: CurrencyUnit, + request: String, + expiry: u64, + secret_key: Option, + ) -> Self { + Self { + id, + mint_url, + payment_method, + amount, + unit, + request, + state: MintQuoteState::Unpaid, + expiry, + secret_key, + amount_issued: Amount::ZERO, + amount_paid: Amount::ZERO, + } + } + + /// Calculate the total amount including any fees + pub fn total_amount(&self) -> Amount { + self.amount_paid + } + + /// Check if the quote has expired + pub fn is_expired(&self, current_time: u64) -> bool { + current_time > self.expiry + } + + /// Amount that can be minted + pub fn amount_mintable(&self) -> Amount { + if self.amount_issued > self.amount_paid { + return Amount::ZERO; + } + + let difference = self.amount_paid - self.amount_issued; + + if difference == Amount::ZERO && self.state != MintQuoteState::Issued { + if let Some(amount) = self.amount { + return amount; + } + } + + difference + } +} + /// Send Kind #[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Default, Serialize, Deserialize)] pub enum SendKind { diff --git a/crates/cdk-common/src/ws.rs b/crates/cdk-common/src/ws.rs index 91ca5d22..1f2e5246 100644 --- a/crates/cdk-common/src/ws.rs +++ b/crates/cdk-common/src/ws.rs @@ -60,6 +60,9 @@ pub fn notification_uuid_to_notification_string( NotificationPayload::MintQuoteBolt11Response(quote) => { NotificationPayload::MintQuoteBolt11Response(quote.to_string_id()) } + NotificationPayload::MintQuoteBolt12Response(quote) => { + NotificationPayload::MintQuoteBolt12Response(quote.to_string_id()) + } }, } } diff --git a/crates/cdk-fake-wallet/Cargo.toml b/crates/cdk-fake-wallet/Cargo.toml index 5f89a677..0b98f503 100644 --- a/crates/cdk-fake-wallet/Cargo.toml +++ b/crates/cdk-fake-wallet/Cargo.toml @@ -13,7 +13,7 @@ readme = "README.md" [dependencies] async-trait.workspace = true bitcoin.workspace = true -cdk = { workspace = true, features = ["mint"] } +cdk-common = { workspace = true, features = ["mint"] } futures.workspace = true tokio.workspace = true tokio-util.workspace = true @@ -22,5 +22,6 @@ thiserror.workspace = true serde.workspace = true serde_json.workspace = true lightning-invoice.workspace = true +lightning.workspace = true tokio-stream.workspace = true reqwest.workspace = true diff --git a/crates/cdk-fake-wallet/src/error.rs b/crates/cdk-fake-wallet/src/error.rs index 69ee0321..f52dbc6e 100644 --- a/crates/cdk-fake-wallet/src/error.rs +++ b/crates/cdk-fake-wallet/src/error.rs @@ -16,7 +16,7 @@ pub enum Error { NoReceiver, } -impl From for cdk::cdk_payment::Error { +impl From for cdk_common::payment::Error { fn from(e: Error) -> Self { Self::Lightning(Box::new(e)) } diff --git a/crates/cdk-fake-wallet/src/lib.rs b/crates/cdk-fake-wallet/src/lib.rs index fa9ef562..38d241b7 100644 --- a/crates/cdk-fake-wallet/src/lib.rs +++ b/crates/cdk-fake-wallet/src/lib.rs @@ -9,7 +9,6 @@ use std::cmp::max; use std::collections::{HashMap, HashSet}; use std::pin::Pin; -use std::str::FromStr; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; @@ -17,22 +16,23 @@ use async_trait::async_trait; use bitcoin::hashes::{sha256, Hash}; use bitcoin::secp256k1::rand::{thread_rng, Rng}; use bitcoin::secp256k1::{Secp256k1, SecretKey}; -use cdk::amount::{to_unit, Amount}; -use cdk::cdk_payment::{ - self, Bolt11Settings, CreateIncomingPaymentResponse, MakePaymentResponse, MintPayment, - PaymentQuoteResponse, +use cdk_common::amount::{to_unit, Amount}; +use cdk_common::common::FeeReserve; +use cdk_common::ensure_cdk; +use cdk_common::nuts::{CurrencyUnit, MeltOptions, MeltQuoteState}; +use cdk_common::payment::{ + self, Bolt11Settings, CreateIncomingPaymentResponse, IncomingPaymentOptions, + MakePaymentResponse, MintPayment, OutgoingPaymentOptions, PaymentIdentifier, + PaymentQuoteResponse, WaitPaymentResponse, }; -use cdk::nuts::{CurrencyUnit, MeltOptions, MeltQuoteState, MintQuoteState}; -use cdk::types::FeeReserve; -use cdk::{ensure_cdk, mint}; use error::Error; use futures::stream::StreamExt; use futures::Stream; +use lightning::offers::offer::OfferBuilder; use lightning_invoice::{Bolt11Invoice, Currency, InvoiceBuilder, PaymentSecret}; -use reqwest::Client; use serde::{Deserialize, Serialize}; use serde_json::Value; -use tokio::sync::Mutex; +use tokio::sync::{Mutex, RwLock}; use tokio::time; use tokio_stream::wrappers::ReceiverStream; use tokio_util::sync::CancellationToken; @@ -44,13 +44,16 @@ pub mod error; #[derive(Clone)] pub struct FakeWallet { fee_reserve: FeeReserve, - sender: tokio::sync::mpsc::Sender, - receiver: Arc>>>, + #[allow(clippy::type_complexity)] + sender: tokio::sync::mpsc::Sender<(PaymentIdentifier, Amount)>, + #[allow(clippy::type_complexity)] + receiver: Arc>>>, payment_states: Arc>>, failed_payment_check: Arc>>, payment_delay: u64, wait_invoice_cancel_token: CancellationToken, wait_invoice_is_active: Arc, + incoming_payments: Arc>>>, } impl FakeWallet { @@ -72,6 +75,7 @@ impl FakeWallet { payment_delay, wait_invoice_cancel_token: CancellationToken::new(), wait_invoice_is_active: Arc::new(AtomicBool::new(false)), + incoming_payments: Arc::new(RwLock::new(HashMap::new())), } } } @@ -102,7 +106,7 @@ impl Default for FakeInvoiceDescription { #[async_trait] impl MintPayment for FakeWallet { - type Err = cdk_payment::Error; + type Err = payment::Error; #[instrument(skip_all)] async fn get_settings(&self) -> Result { @@ -111,6 +115,7 @@ impl MintPayment for FakeWallet { unit: CurrencyUnit::Msat, invoice_description: true, amountless: false, + bolt12: false, })?) } @@ -127,51 +132,86 @@ impl MintPayment for FakeWallet { #[instrument(skip_all)] async fn wait_any_incoming_payment( &self, - ) -> Result + Send>>, Self::Err> { + ) -> Result + Send>>, Self::Err> { tracing::info!("Starting stream for fake invoices"); - let receiver = self.receiver.lock().await.take().ok_or(Error::NoReceiver)?; + let receiver = self + .receiver + .lock() + .await + .take() + .ok_or(Error::NoReceiver) + .unwrap(); let receiver_stream = ReceiverStream::new(receiver); - Ok(Box::pin(receiver_stream.map(|label| label))) + Ok(Box::pin(receiver_stream.map( + |(request_lookup_id, payment_amount)| WaitPaymentResponse { + payment_identifier: request_lookup_id.clone(), + payment_amount, + unit: CurrencyUnit::Sat, + payment_id: request_lookup_id.to_string(), + }, + ))) } #[instrument(skip_all)] async fn get_payment_quote( &self, - request: &str, unit: &CurrencyUnit, - options: Option, + options: OutgoingPaymentOptions, ) -> Result { - let bolt11 = Bolt11Invoice::from_str(request)?; + let (amount_msat, request_lookup_id) = match options { + OutgoingPaymentOptions::Bolt11(bolt11_options) => { + // If we have specific amount options, use those + let amount_msat: u64 = if let Some(melt_options) = bolt11_options.melt_options { + let msats = match melt_options { + MeltOptions::Amountless { amountless } => { + let amount_msat = amountless.amount_msat; - let amount_msat = match options { - Some(amount) => amount.amount_msat(), - None => bolt11 - .amount_milli_satoshis() - .ok_or(Error::UnknownInvoiceAmount)? - .into(), + if let Some(invoice_amount) = + bolt11_options.bolt11.amount_milli_satoshis() + { + ensure_cdk!( + invoice_amount == u64::from(amount_msat), + Error::UnknownInvoiceAmount.into() + ); + } + amount_msat + } + MeltOptions::Mpp { mpp } => mpp.amount, + }; + + u64::from(msats) + } else { + // Fall back to invoice amount + bolt11_options + .bolt11 + .amount_milli_satoshis() + .ok_or(Error::UnknownInvoiceAmount)? + }; + let payment_id = + PaymentIdentifier::PaymentHash(*bolt11_options.bolt11.payment_hash().as_ref()); + (amount_msat, payment_id) + } + OutgoingPaymentOptions::Bolt12(bolt12_options) => { + let offer = bolt12_options.offer; + + let amount_msat: u64 = if let Some(amount) = bolt12_options.melt_options { + amount.amount_msat().into() + } else { + // Fall back to offer amount + let amount = offer.amount().ok_or(Error::UnknownInvoiceAmount)?; + match amount { + lightning::offers::offer::Amount::Bitcoin { amount_msats } => amount_msats, + _ => return Err(Error::UnknownInvoiceAmount.into()), + } + }; + ( + amount_msat, + PaymentIdentifier::OfferId(offer.id().to_string()), + ) + } }; - let amount = if unit != &CurrencyUnit::Sat && unit != &CurrencyUnit::Msat { - let client = Client::new(); - - let response: Value = client - .get("https://mempool.space/api/v1/prices") - .send() - .await - .map_err(|_| Error::UnknownInvoice)? - .json() - .await - .unwrap(); - - let price = response.get(unit.to_string().to_uppercase()).unwrap(); - - let bitcoin_amount = u64::from(amount_msat) as f64 / 100_000_000_000.0; - let total_price = price.as_f64().unwrap() * bitcoin_amount; - - Amount::from((total_price * 100.0).ceil() as u64) - } else { - to_unit(amount_msat, &CurrencyUnit::Msat, unit)? - }; + let amount = to_unit(amount_msat, &CurrencyUnit::Msat, unit)?; let relative_fee_reserve = (self.fee_reserve.percent_fee_reserve * u64::from(amount) as f32) as u64; @@ -181,96 +221,198 @@ impl MintPayment for FakeWallet { let fee = max(relative_fee_reserve, absolute_fee_reserve); Ok(PaymentQuoteResponse { - request_lookup_id: bolt11.payment_hash().to_string(), + request_lookup_id, amount, fee: fee.into(), - unit: unit.clone(), state: MeltQuoteState::Unpaid, + options: None, + unit: unit.clone(), }) } #[instrument(skip_all)] async fn make_payment( &self, - melt_quote: mint::MeltQuote, - _partial_msats: Option, - _max_fee_msats: Option, + unit: &CurrencyUnit, + options: OutgoingPaymentOptions, ) -> Result { - let bolt11 = Bolt11Invoice::from_str(&melt_quote.request)?; + match options { + OutgoingPaymentOptions::Bolt11(bolt11_options) => { + let bolt11 = bolt11_options.bolt11; + let payment_hash = bolt11.payment_hash().to_string(); - let payment_hash = bolt11.payment_hash().to_string(); + let description = bolt11.description().to_string(); - let description = bolt11.description().to_string(); + let status: Option = + serde_json::from_str(&description).ok(); - let status: Option = serde_json::from_str(&description).ok(); + let mut payment_states = self.payment_states.lock().await; + let payment_status = status + .clone() + .map(|s| s.pay_invoice_state) + .unwrap_or(MeltQuoteState::Paid); - let mut payment_states = self.payment_states.lock().await; - let payment_status = status - .clone() - .map(|s| s.pay_invoice_state) - .unwrap_or(MeltQuoteState::Paid); + let checkout_going_status = status + .clone() + .map(|s| s.check_payment_state) + .unwrap_or(MeltQuoteState::Paid); - let checkout_going_status = status - .clone() - .map(|s| s.check_payment_state) - .unwrap_or(MeltQuoteState::Paid); + payment_states.insert(payment_hash.clone(), checkout_going_status); - payment_states.insert(payment_hash.clone(), checkout_going_status); + if let Some(description) = status { + if description.check_err { + let mut fail = self.failed_payment_check.lock().await; + fail.insert(payment_hash.clone()); + } - if let Some(description) = status { - if description.check_err { - let mut fail = self.failed_payment_check.lock().await; - fail.insert(payment_hash.clone()); + ensure_cdk!(!description.pay_err, Error::UnknownInvoice.into()); + } + + let amount_msat: u64 = if let Some(melt_options) = bolt11_options.melt_options { + melt_options.amount_msat().into() + } else { + // Fall back to invoice amount + bolt11 + .amount_milli_satoshis() + .ok_or(Error::UnknownInvoiceAmount)? + }; + + let total_spent = to_unit(amount_msat, &CurrencyUnit::Msat, unit)?; + + Ok(MakePaymentResponse { + payment_proof: Some("".to_string()), + payment_lookup_id: PaymentIdentifier::PaymentHash( + *bolt11.payment_hash().as_ref(), + ), + status: payment_status, + total_spent: total_spent + 1.into(), + unit: unit.clone(), + }) } + OutgoingPaymentOptions::Bolt12(bolt12_options) => { + let bolt12 = bolt12_options.offer; + let amount_msat: u64 = if let Some(amount) = bolt12_options.melt_options { + amount.amount_msat().into() + } else { + // Fall back to offer amount + let amount = bolt12.amount().ok_or(Error::UnknownInvoiceAmount)?; + match amount { + lightning::offers::offer::Amount::Bitcoin { amount_msats } => amount_msats, + _ => return Err(Error::UnknownInvoiceAmount.into()), + } + }; - ensure_cdk!(!description.pay_err, Error::UnknownInvoice.into()); + let total_spent = to_unit(amount_msat, &CurrencyUnit::Msat, unit)?; + + Ok(MakePaymentResponse { + payment_proof: Some("".to_string()), + payment_lookup_id: PaymentIdentifier::OfferId(bolt12.id().to_string()), + status: MeltQuoteState::Paid, + total_spent: total_spent + 1.into(), + unit: unit.clone(), + }) + } } - - Ok(MakePaymentResponse { - payment_proof: Some("".to_string()), - payment_lookup_id: payment_hash, - status: payment_status, - total_spent: melt_quote.amount + 1.into(), - unit: melt_quote.unit, - }) } #[instrument(skip_all)] async fn create_incoming_payment_request( &self, - amount: Amount, - _unit: &CurrencyUnit, - description: String, - _unix_expiry: Option, + unit: &CurrencyUnit, + options: IncomingPaymentOptions, ) -> Result { - // Since this is fake we just use the amount no matter the unit to create an invoice - let amount_msat = amount; + let (payment_hash, request, amount, expiry) = match options { + IncomingPaymentOptions::Bolt12(bolt12_options) => { + let description = bolt12_options.description.unwrap_or_default(); + let amount = bolt12_options.amount; + let expiry = bolt12_options.unix_expiry; - let invoice = create_fake_invoice(amount_msat.into(), description); + let secret_key = SecretKey::new(&mut thread_rng()); + let secp_ctx = Secp256k1::new(); + + let offer_builder = OfferBuilder::new(secret_key.public_key(&secp_ctx)) + .description(description.clone()); + + let offer_builder = match amount { + Some(amount) => { + let amount_msat = to_unit(amount, unit, &CurrencyUnit::Msat)?; + offer_builder.amount_msats(amount_msat.into()) + } + None => offer_builder, + }; + + let offer = offer_builder.build().unwrap(); + + ( + PaymentIdentifier::OfferId(offer.id().to_string()), + offer.to_string(), + amount.unwrap_or(Amount::ZERO), + expiry, + ) + } + IncomingPaymentOptions::Bolt11(bolt11_options) => { + let description = bolt11_options.description.unwrap_or_default(); + let amount = bolt11_options.amount; + let expiry = bolt11_options.unix_expiry; + + // Since this is fake we just use the amount no matter the unit to create an invoice + let invoice = create_fake_invoice(amount.into(), description.clone()); + let payment_hash = invoice.payment_hash(); + + ( + PaymentIdentifier::PaymentHash(*payment_hash.as_ref()), + invoice.to_string(), + amount, + expiry, + ) + } + }; let sender = self.sender.clone(); - - let payment_hash = invoice.payment_hash(); - - let payment_hash_clone = payment_hash.to_string(); - let duration = time::Duration::from_secs(self.payment_delay); + let final_amount = if amount == Amount::ZERO { + let mut rng = thread_rng(); + // Generate a random number between 1 and 1000 (inclusive) + let random_number: u64 = rng.gen_range(1..=1000); + random_number.into() + } else { + amount + }; + + let payment_hash_clone = payment_hash.clone(); + + let incoming_payment = self.incoming_payments.clone(); + tokio::spawn(async move { // Wait for the random delay to elapse time::sleep(duration).await; + let response = WaitPaymentResponse { + payment_identifier: payment_hash_clone.clone(), + payment_amount: final_amount, + unit: CurrencyUnit::Sat, + payment_id: payment_hash_clone.to_string(), + }; + let mut incoming = incoming_payment.write().await; + incoming + .entry(payment_hash_clone.clone()) + .or_insert_with(Vec::new) + .push(response.clone()); + // Send the message after waiting for the specified duration - if sender.send(payment_hash_clone.clone()).await.is_err() { - tracing::error!("Failed to send label: {}", payment_hash_clone); + if sender + .send((payment_hash_clone.clone(), final_amount)) + .await + .is_err() + { + tracing::error!("Failed to send label: {:?}", payment_hash_clone); } }); - let expiry = invoice.expires_at().map(|t| t.as_secs()); - Ok(CreateIncomingPaymentResponse { - request_lookup_id: payment_hash.to_string(), - request: invoice.to_string(), + request_lookup_id: payment_hash, + request, expiry, }) } @@ -278,31 +420,37 @@ impl MintPayment for FakeWallet { #[instrument(skip_all)] async fn check_incoming_payment_status( &self, - _request_lookup_id: &str, - ) -> Result { - Ok(MintQuoteState::Paid) + request_lookup_id: &PaymentIdentifier, + ) -> Result, Self::Err> { + Ok(self + .incoming_payments + .read() + .await + .get(request_lookup_id) + .cloned() + .unwrap_or(vec![])) } #[instrument(skip_all)] async fn check_outgoing_payment( &self, - request_lookup_id: &str, + request_lookup_id: &PaymentIdentifier, ) -> Result { // For fake wallet if the state is not explicitly set default to paid let states = self.payment_states.lock().await; - let status = states.get(request_lookup_id).cloned(); + let status = states.get(&request_lookup_id.to_string()).cloned(); let status = status.unwrap_or(MeltQuoteState::Paid); let fail_payments = self.failed_payment_check.lock().await; - if fail_payments.contains(request_lookup_id) { - return Err(cdk_payment::Error::InvoicePaymentPending); + if fail_payments.contains(&request_lookup_id.to_string()) { + return Err(payment::Error::InvoicePaymentPending); } Ok(MakePaymentResponse { payment_proof: Some("".to_string()), - payment_lookup_id: request_lookup_id.to_string(), + payment_lookup_id: request_lookup_id.clone(), status, total_spent: Amount::ZERO, unit: CurrencyUnit::Msat, diff --git a/crates/cdk-integration-tests/src/init_pure_tests.rs b/crates/cdk-integration-tests/src/init_pure_tests.rs index 155f03e3..aafe5c60 100644 --- a/crates/cdk-integration-tests/src/init_pure_tests.rs +++ b/crates/cdk-integration-tests/src/init_pure_tests.rs @@ -8,6 +8,7 @@ use std::{env, fs}; use anyhow::{anyhow, bail, Result}; use async_trait::async_trait; use bip39::Mnemonic; +use cashu::{MeltQuoteBolt12Request, MintQuoteBolt12Request, MintQuoteBolt12Response}; use cdk::amount::SplitTarget; use cdk::cdk_database::{self, WalletDatabase}; use cdk::mint::{MintBuilder, MintMeltLimits}; @@ -72,7 +73,7 @@ impl MintConnector for DirectMintConnection { request: MintQuoteBolt11Request, ) -> Result, Error> { self.mint - .get_mint_bolt11_quote(request) + .get_mint_quote(request.into()) .await .map(Into::into) } @@ -98,7 +99,7 @@ impl MintConnector for DirectMintConnection { request: MeltQuoteBolt11Request, ) -> Result, Error> { self.mint - .get_melt_bolt11_quote(&request) + .get_melt_quote(request.into()) .await .map(Into::into) } @@ -119,7 +120,7 @@ impl MintConnector for DirectMintConnection { request: MeltRequest, ) -> Result, Error> { let request_uuid = request.try_into().unwrap(); - self.mint.melt_bolt11(&request_uuid).await.map(Into::into) + self.mint.melt(&request_uuid).await.map(Into::into) } async fn post_swap(&self, swap_request: SwapRequest) -> Result { @@ -152,6 +153,59 @@ impl MintConnector for DirectMintConnection { *auth_wallet = wallet; } + + async fn post_mint_bolt12_quote( + &self, + request: MintQuoteBolt12Request, + ) -> Result, Error> { + let res: MintQuoteBolt12Response = + self.mint.get_mint_quote(request.into()).await?.try_into()?; + Ok(res.into()) + } + + async fn get_mint_quote_bolt12_status( + &self, + quote_id: &str, + ) -> Result, Error> { + let quote_id_uuid = Uuid::from_str(quote_id).unwrap(); + let quote: MintQuoteBolt12Response = self + .mint + .check_mint_quote("e_id_uuid) + .await? + .try_into()?; + + Ok(quote.into()) + } + + /// Melt Quote [NUT-23] + async fn post_melt_bolt12_quote( + &self, + request: MeltQuoteBolt12Request, + ) -> Result, Error> { + self.mint + .get_melt_quote(request.into()) + .await + .map(Into::into) + } + /// Melt Quote Status [NUT-23] + async fn get_melt_bolt12_quote_status( + &self, + quote_id: &str, + ) -> Result, Error> { + let quote_id_uuid = Uuid::from_str(quote_id).unwrap(); + self.mint + .check_melt_quote("e_id_uuid) + .await + .map(Into::into) + } + /// Melt [NUT-23] + async fn post_melt_bolt12( + &self, + _request: MeltRequest, + ) -> Result, Error> { + // Implementation to be added later + Err(Error::UnsupportedPaymentMethod) + } } pub fn setup_tracing() { diff --git a/crates/cdk-integration-tests/src/lib.rs b/crates/cdk-integration-tests/src/lib.rs index 30475711..2fb7d062 100644 --- a/crates/cdk-integration-tests/src/lib.rs +++ b/crates/cdk-integration-tests/src/lib.rs @@ -2,7 +2,7 @@ use std::env; use std::sync::Arc; use anyhow::{anyhow, bail, Result}; -use cashu::Bolt11Invoice; +use cashu::{Bolt11Invoice, PaymentMethod}; use cdk::amount::{Amount, SplitTarget}; use cdk::nuts::{MintQuoteState, NotificationPayload, State}; use cdk::wallet::WalletSubscription; @@ -86,6 +86,10 @@ pub async fn wait_for_mint_to_be_paid( if response.state == MintQuoteState::Paid { return Ok(()); } + } else if let NotificationPayload::MintQuoteBolt12Response(response) = msg { + if response.amount_paid > Amount::ZERO { + return Ok(()); + } } } Err(anyhow!("Subscription ended without quote being paid")) @@ -95,18 +99,40 @@ pub async fn wait_for_mint_to_be_paid( let check_interval = Duration::from_secs(5); + let method = wallet + .localstore + .get_mint_quote(mint_quote_id) + .await? + .map(|q| q.payment_method) + .unwrap_or_default(); + let periodic_task = async { loop { - match wallet.mint_quote_state(mint_quote_id).await { - Ok(result) => { - if result.state == MintQuoteState::Paid { - tracing::info!("mint quote paid via poll"); - return Ok(()); + match method { + PaymentMethod::Bolt11 => match wallet.mint_quote_state(mint_quote_id).await { + Ok(result) => { + if result.state == MintQuoteState::Paid { + tracing::info!("mint quote paid via poll"); + return Ok(()); + } + } + Err(e) => { + tracing::error!("Could not check mint quote status: {:?}", e); + } + }, + PaymentMethod::Bolt12 => { + match wallet.mint_bolt12_quote_state(mint_quote_id).await { + Ok(result) => { + if result.amount_paid > Amount::ZERO { + return Ok(()); + } + } + Err(e) => { + tracing::error!("Could not check mint quote status: {:?}", e); + } } } - Err(e) => { - tracing::error!("Could not check mint quote status: {:?}", e); - } + PaymentMethod::Custom(_) => (), } sleep(check_interval).await; } @@ -166,7 +192,6 @@ pub async fn init_lnd_client() -> LndClient { pub async fn pay_if_regtest(invoice: &Bolt11Invoice) -> Result<()> { // Check if the invoice is for the regtest network if invoice.network() == bitcoin::Network::Regtest { - println!("Regtest invoice"); let lnd_client = init_lnd_client().await; lnd_client.pay_invoice(invoice.to_string()).await?; Ok(()) diff --git a/crates/cdk-integration-tests/tests/bolt12.rs b/crates/cdk-integration-tests/tests/bolt12.rs new file mode 100644 index 00000000..5a265c89 --- /dev/null +++ b/crates/cdk-integration-tests/tests/bolt12.rs @@ -0,0 +1,332 @@ +use std::sync::Arc; + +use anyhow::{bail, Result}; +use bip39::Mnemonic; +use cashu::amount::SplitTarget; +use cashu::nut23::Amountless; +use cashu::{Amount, CurrencyUnit, MintRequest, PreMintSecrets, ProofsMethods}; +use cdk::wallet::{HttpClient, MintConnector, Wallet}; +use cdk_integration_tests::init_regtest::get_cln_dir; +use cdk_integration_tests::{get_mint_url_from_env, wait_for_mint_to_be_paid}; +use cdk_sqlite::wallet::memory; +use ln_regtest_rs::ln_client::ClnClient; + +/// Tests basic BOLT12 minting functionality: +/// - Creates a wallet +/// - Gets a BOLT12 quote for a specific amount (100 sats) +/// - Pays the quote using Core Lightning +/// - Mints tokens and verifies the correct amount is received +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_regtest_bolt12_mint() { + let wallet = Wallet::new( + &get_mint_url_from_env(), + CurrencyUnit::Sat, + Arc::new(memory::empty().await.unwrap()), + &Mnemonic::generate(12).unwrap().to_seed_normalized(""), + None, + ) + .unwrap(); + + let mint_amount = Amount::from(100); + + let mint_quote = wallet + .mint_bolt12_quote(Some(mint_amount), None) + .await + .unwrap(); + + assert_eq!(mint_quote.amount, Some(mint_amount)); + + let cln_one_dir = get_cln_dir("one"); + let cln_client = ClnClient::new(cln_one_dir.clone(), None).await.unwrap(); + cln_client + .pay_bolt12_offer(None, mint_quote.request) + .await + .unwrap(); + + let proofs = wallet + .mint_bolt12(&mint_quote.id, None, SplitTarget::default(), None) + .await + .unwrap(); + + assert_eq!(proofs.total_amount().unwrap(), 100.into()); +} + +/// Tests multiple payments to a single BOLT12 quote: +/// - Creates a wallet and gets a BOLT12 quote without specifying amount +/// - Makes two separate payments (10,000 sats and 11,000 sats) to the same quote +/// - Verifies that each payment can be minted separately and correctly +/// - Tests the functionality of reusing a quote for multiple payments +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_regtest_bolt12_mint_multiple() -> Result<()> { + let wallet = Wallet::new( + &get_mint_url_from_env(), + CurrencyUnit::Sat, + Arc::new(memory::empty().await?), + &Mnemonic::generate(12)?.to_seed_normalized(""), + None, + )?; + + let mint_quote = wallet.mint_bolt12_quote(None, None).await?; + + let cln_one_dir = get_cln_dir("one"); + let cln_client = ClnClient::new(cln_one_dir.clone(), None).await?; + cln_client + .pay_bolt12_offer(Some(10000), mint_quote.request.clone()) + .await + .unwrap(); + + wait_for_mint_to_be_paid(&wallet, &mint_quote.id, 60).await?; + + wallet.mint_bolt12_quote_state(&mint_quote.id).await?; + + let proofs = wallet + .mint_bolt12(&mint_quote.id, None, SplitTarget::default(), None) + .await + .unwrap(); + + assert_eq!(proofs.total_amount().unwrap(), 10.into()); + + cln_client + .pay_bolt12_offer(Some(11_000), mint_quote.request) + .await + .unwrap(); + + wait_for_mint_to_be_paid(&wallet, &mint_quote.id, 60).await?; + + wallet.mint_bolt12_quote_state(&mint_quote.id).await?; + + let proofs = wallet + .mint_bolt12(&mint_quote.id, None, SplitTarget::default(), None) + .await + .unwrap(); + + assert_eq!(proofs.total_amount().unwrap(), 11.into()); + + Ok(()) +} + +/// Tests that multiple wallets can pay the same BOLT12 offer: +/// - Creates a BOLT12 offer through CLN that both wallets will pay +/// - Creates two separate wallets with different minting amounts +/// - Has each wallet get their own quote and make payments +/// - Verifies both wallets can successfully mint their tokens +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_regtest_bolt12_multiple_wallets() -> Result<()> { + // Create first wallet + let wallet_one = Wallet::new( + &get_mint_url_from_env(), + CurrencyUnit::Sat, + Arc::new(memory::empty().await?), + &Mnemonic::generate(12)?.to_seed_normalized(""), + None, + )?; + + // Create second wallet + let wallet_two = Wallet::new( + &get_mint_url_from_env(), + CurrencyUnit::Sat, + Arc::new(memory::empty().await?), + &Mnemonic::generate(12)?.to_seed_normalized(""), + None, + )?; + + // Create a BOLT12 offer that both wallets will use + let cln_one_dir = get_cln_dir("one"); + let cln_client = ClnClient::new(cln_one_dir.clone(), None).await?; + // First wallet payment + let quote_one = wallet_one + .mint_bolt12_quote(Some(10_000.into()), None) + .await?; + cln_client + .pay_bolt12_offer(None, quote_one.request.clone()) + .await?; + wait_for_mint_to_be_paid(&wallet_one, "e_one.id, 60).await?; + let proofs_one = wallet_one + .mint_bolt12("e_one.id, None, SplitTarget::default(), None) + .await?; + + assert_eq!(proofs_one.total_amount()?, 10_000.into()); + + // Second wallet payment + let quote_two = wallet_two + .mint_bolt12_quote(Some(15_000.into()), None) + .await?; + cln_client + .pay_bolt12_offer(None, quote_two.request.clone()) + .await?; + wait_for_mint_to_be_paid(&wallet_two, "e_two.id, 60).await?; + + let proofs_two = wallet_two + .mint_bolt12("e_two.id, None, SplitTarget::default(), None) + .await?; + assert_eq!(proofs_two.total_amount()?, 15_000.into()); + + let offer = cln_client + .get_bolt12_offer(None, false, "test_multiple_wallets".to_string()) + .await?; + + let wallet_one_melt_quote = wallet_one + .melt_bolt12_quote( + offer.to_string(), + Some(cashu::MeltOptions::Amountless { + amountless: Amountless { + amount_msat: 1500.into(), + }, + }), + ) + .await?; + + let wallet_two_melt_quote = wallet_two + .melt_bolt12_quote( + offer.to_string(), + Some(cashu::MeltOptions::Amountless { + amountless: Amountless { + amount_msat: 1000.into(), + }, + }), + ) + .await?; + + let melted = wallet_one.melt(&wallet_one_melt_quote.id).await?; + + assert!(melted.preimage.is_some()); + + let melted_two = wallet_two.melt(&wallet_two_melt_quote.id).await?; + + assert!(melted_two.preimage.is_some()); + + Ok(()) +} + +/// Tests the BOLT12 melting (spending) functionality: +/// - Creates a wallet and mints 20,000 sats using BOLT12 +/// - Creates a BOLT12 offer for 10,000 sats +/// - Tests melting (spending) tokens using the BOLT12 offer +/// - Verifies the correct amount is melted +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_regtest_bolt12_melt() -> Result<()> { + let wallet = Wallet::new( + &get_mint_url_from_env(), + CurrencyUnit::Sat, + Arc::new(memory::empty().await?), + &Mnemonic::generate(12)?.to_seed_normalized(""), + None, + )?; + + wallet.get_mint_info().await?; + + let mint_amount = Amount::from(20_000); + + // Create a single-use BOLT12 quote + let mint_quote = wallet.mint_bolt12_quote(Some(mint_amount), None).await?; + + assert_eq!(mint_quote.amount, Some(mint_amount)); + // Pay the quote + let cln_one_dir = get_cln_dir("one"); + let cln_client = ClnClient::new(cln_one_dir.clone(), None).await?; + cln_client + .pay_bolt12_offer(None, mint_quote.request.clone()) + .await?; + + // Wait for payment to be processed + wait_for_mint_to_be_paid(&wallet, &mint_quote.id, 60).await?; + + let offer = cln_client + .get_bolt12_offer(Some(10_000), true, "hhhhhhhh".to_string()) + .await?; + + let _proofs = wallet + .mint_bolt12(&mint_quote.id, None, SplitTarget::default(), None) + .await + .unwrap(); + + let quote = wallet.melt_bolt12_quote(offer.to_string(), None).await?; + + let melt = wallet.melt("e.id).await?; + + assert_eq!(melt.amount, 10.into()); + + Ok(()) +} + +/// Tests security validation for BOLT12 minting to prevent overspending: +/// - Creates a wallet and gets an open-ended BOLT12 quote +/// - Makes a payment of 10,000 millisats +/// - Attempts to mint more tokens (500 sats) than were actually paid for +/// - Verifies that the mint correctly rejects the oversized mint request +/// - Ensures proper error handling with TransactionUnbalanced error +/// This test is crucial for ensuring the economic security of the minting process +/// by preventing users from minting more tokens than they have paid for. +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_regtest_bolt12_mint_extra() -> Result<()> { + let wallet = Wallet::new( + &get_mint_url_from_env(), + CurrencyUnit::Sat, + Arc::new(memory::empty().await?), + &Mnemonic::generate(12)?.to_seed_normalized(""), + None, + )?; + + wallet.get_mint_info().await?; + + // Create a single-use BOLT12 quote + let mint_quote = wallet.mint_bolt12_quote(None, None).await?; + + let state = wallet.mint_bolt12_quote_state(&mint_quote.id).await?; + + assert_eq!(state.amount_paid, Amount::ZERO); + assert_eq!(state.amount_issued, Amount::ZERO); + + let active_keyset_id = wallet.get_active_mint_keyset().await?.id; + + let pay_amount_msats = 10_000; + + let cln_one_dir = get_cln_dir("one"); + let cln_client = ClnClient::new(cln_one_dir.clone(), None).await?; + cln_client + .pay_bolt12_offer(Some(pay_amount_msats), mint_quote.request.clone()) + .await?; + + wait_for_mint_to_be_paid(&wallet, &mint_quote.id, 10).await?; + + let state = wallet.mint_bolt12_quote_state(&mint_quote.id).await?; + + assert_eq!(state.amount_paid, (pay_amount_msats / 1_000).into()); + assert_eq!(state.amount_issued, Amount::ZERO); + + let pre_mint = PreMintSecrets::random(active_keyset_id, 500.into(), &SplitTarget::None)?; + + let quote_info = wallet + .localstore + .get_mint_quote(&mint_quote.id) + .await? + .expect("there is a quote"); + + let mut mint_request = MintRequest { + quote: mint_quote.id, + outputs: pre_mint.blinded_messages(), + signature: None, + }; + + if let Some(secret_key) = quote_info.secret_key { + mint_request.sign(secret_key)?; + } + + let http_client = HttpClient::new(get_mint_url_from_env().parse().unwrap(), None); + + let response = http_client.post_mint(mint_request.clone()).await; + + match response { + Err(err) => match err { + cdk::Error::TransactionUnbalanced(_, _, _) => (), + err => { + bail!("Wrong mint error returned: {}", err.to_string()); + } + }, + Ok(_) => { + bail!("Should not have allowed second payment"); + } + } + + Ok(()) +} diff --git a/crates/cdk-integration-tests/tests/fake_auth.rs b/crates/cdk-integration-tests/tests/fake_auth.rs index d9c97514..bf2134bd 100644 --- a/crates/cdk-integration-tests/tests/fake_auth.rs +++ b/crates/cdk-integration-tests/tests/fake_auth.rs @@ -514,7 +514,7 @@ async fn test_reuse_auth_proof() { .await .expect("Quote should be allowed"); - assert!(quote.amount == 10.into()); + assert!(quote.amount == Some(10.into())); } wallet @@ -645,7 +645,7 @@ async fn test_refresh_access_token() { .await .expect("failed to get mint quote with refreshed token"); - assert_eq!(mint_quote.amount, mint_amount); + assert_eq!(mint_quote.amount, Some(mint_amount)); // Verify the total number of auth tokens let total_auth_proofs = wallet.get_unspent_auth_proofs().await.unwrap(); @@ -731,7 +731,7 @@ async fn test_auth_token_spending_order() { .await .expect("failed to get mint quote"); - assert_eq!(mint_quote.amount, 10.into()); + assert_eq!(mint_quote.amount, Some(10.into())); // Check remaining tokens after each operation let remaining = wallet.get_unspent_auth_proofs().await.unwrap(); diff --git a/crates/cdk-integration-tests/tests/happy_path_mint_wallet.rs b/crates/cdk-integration-tests/tests/happy_path_mint_wallet.rs index 2f596388..e9311196 100644 --- a/crates/cdk-integration-tests/tests/happy_path_mint_wallet.rs +++ b/crates/cdk-integration-tests/tests/happy_path_mint_wallet.rs @@ -100,6 +100,10 @@ async fn test_happy_mint_melt_round_trip() { let invoice = Bolt11Invoice::from_str(&mint_quote.request).unwrap(); pay_if_regtest(&invoice).await.unwrap(); + wait_for_mint_to_be_paid(&wallet, &mint_quote.id, 10) + .await + .unwrap(); + let proofs = wallet .mint(&mint_quote.id, SplitTarget::default(), None) .await @@ -210,7 +214,7 @@ async fn test_happy_mint() { let mint_quote = wallet.mint_quote(mint_amount, None).await.unwrap(); - assert_eq!(mint_quote.amount, mint_amount); + assert_eq!(mint_quote.amount, Some(mint_amount)); let invoice = Bolt11Invoice::from_str(&mint_quote.request).unwrap(); pay_if_regtest(&invoice).await.unwrap(); @@ -285,6 +289,8 @@ async fn test_restore() { let restored = wallet_2.restore().await.unwrap(); let proofs = wallet_2.get_unspent_proofs().await.unwrap(); + assert!(!proofs.is_empty()); + let expected_fee = wallet.get_proofs_fee(&proofs).await.unwrap(); wallet_2 .swap(None, SplitTarget::default(), proofs, None, false) @@ -431,7 +437,9 @@ async fn test_pay_invoice_twice() { Err(err) => match err { cdk::Error::RequestAlreadyPaid => (), err => { - panic!("Wrong invoice already paid: {}", err.to_string()); + if !err.to_string().contains("Duplicate entry") { + panic!("Wrong invoice already paid: {}", err.to_string()); + } } }, Ok(_) => { diff --git a/crates/cdk-integration-tests/tests/integration_tests_pure.rs b/crates/cdk-integration-tests/tests/integration_tests_pure.rs index fc60de48..4eb9dd72 100644 --- a/crates/cdk-integration-tests/tests/integration_tests_pure.rs +++ b/crates/cdk-integration-tests/tests/integration_tests_pure.rs @@ -832,11 +832,11 @@ async fn test_concurrent_double_spend_melt() { let melt_request3 = melt_request.clone(); // Spawn 3 concurrent tasks to process the melt requests - let task1 = tokio::spawn(async move { mint_clone1.melt_bolt11(&melt_request).await }); + let task1 = tokio::spawn(async move { mint_clone1.melt(&melt_request).await }); - let task2 = tokio::spawn(async move { mint_clone2.melt_bolt11(&melt_request2).await }); + let task2 = tokio::spawn(async move { mint_clone2.melt(&melt_request2).await }); - let task3 = tokio::spawn(async move { mint_clone3.melt_bolt11(&melt_request3).await }); + let task3 = tokio::spawn(async move { mint_clone3.melt(&melt_request3).await }); // Wait for all tasks to complete let results = tokio::try_join!(task1, task2, task3).expect("Tasks failed to complete"); diff --git a/crates/cdk-integration-tests/tests/regtest.rs b/crates/cdk-integration-tests/tests/regtest.rs index e50aef78..f73d432b 100644 --- a/crates/cdk-integration-tests/tests/regtest.rs +++ b/crates/cdk-integration-tests/tests/regtest.rs @@ -89,7 +89,7 @@ async fn test_internal_payment() { let _melted = wallet.melt(&melt.id).await.unwrap(); - wait_for_mint_to_be_paid(&wallet, &mint_quote.id, 60) + wait_for_mint_to_be_paid(&wallet_2, &mint_quote.id, 60) .await .unwrap(); @@ -348,7 +348,7 @@ async fn test_regtest_melt_amountless() { let mint_quote = wallet.mint_quote(mint_amount, None).await.unwrap(); - assert_eq!(mint_quote.amount, mint_amount); + assert_eq!(mint_quote.amount, Some(mint_amount)); lnd_client .pay_invoice(mint_quote.request) diff --git a/crates/cdk-integration-tests/tests/test_fees.rs b/crates/cdk-integration-tests/tests/test_fees.rs index da7c24f4..0893ab41 100644 --- a/crates/cdk-integration-tests/tests/test_fees.rs +++ b/crates/cdk-integration-tests/tests/test_fees.rs @@ -28,6 +28,10 @@ async fn test_swap() { let invoice = Bolt11Invoice::from_str(&mint_quote.request).unwrap(); pay_if_regtest(&invoice).await.unwrap(); + wait_for_mint_to_be_paid(&wallet, &mint_quote.id, 10) + .await + .unwrap(); + let _mint_amount = wallet .mint(&mint_quote.id, SplitTarget::default(), None) .await diff --git a/crates/cdk-lnbits/src/error.rs b/crates/cdk-lnbits/src/error.rs index d22d69dc..83cc6336 100644 --- a/crates/cdk-lnbits/src/error.rs +++ b/crates/cdk-lnbits/src/error.rs @@ -14,6 +14,9 @@ pub enum Error { /// Amount overflow #[error("Amount overflow")] AmountOverflow, + /// Invalid payment hash + #[error("Invalid payment hash")] + InvalidPaymentHash, /// Anyhow error #[error(transparent)] Anyhow(#[from] anyhow::Error), diff --git a/crates/cdk-lnbits/src/lib.rs b/crates/cdk-lnbits/src/lib.rs index 6a14395f..3703a47e 100644 --- a/crates/cdk-lnbits/src/lib.rs +++ b/crates/cdk-lnbits/src/lib.rs @@ -6,7 +6,6 @@ use std::cmp::max; use std::pin::Pin; -use std::str::FromStr; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; @@ -15,13 +14,14 @@ use async_trait::async_trait; use axum::Router; use cdk_common::amount::{to_unit, Amount, MSAT_IN_SAT}; use cdk_common::common::FeeReserve; -use cdk_common::nuts::{CurrencyUnit, MeltOptions, MeltQuoteState, MintQuoteState}; +use cdk_common::nuts::{CurrencyUnit, MeltOptions, MeltQuoteState}; use cdk_common::payment::{ - self, Bolt11Settings, CreateIncomingPaymentResponse, MakePaymentResponse, MintPayment, - PaymentQuoteResponse, + self, Bolt11Settings, CreateIncomingPaymentResponse, IncomingPaymentOptions, + MakePaymentResponse, MintPayment, OutgoingPaymentOptions, PaymentIdentifier, + PaymentQuoteResponse, WaitPaymentResponse, }; -use cdk_common::util::unix_time; -use cdk_common::{mint, Bolt11Invoice}; +use cdk_common::util::{hex, unix_time}; +use cdk_common::Bolt11Invoice; use error::Error; use futures::Stream; use lnbits_rs::api::invoice::CreateInvoiceRequest; @@ -65,6 +65,7 @@ impl LNbits { unit: CurrencyUnit::Sat, invoice_description: true, amountless: false, + bolt12: false, }, }) } @@ -99,7 +100,7 @@ impl MintPayment for LNbits { async fn wait_any_incoming_payment( &self, - ) -> Result + Send>>, Self::Err> { + ) -> Result + Send>>, Self::Err> { let api = self.lnbits_api.clone(); let cancel_token = self.wait_invoice_cancel_token.clone(); let is_active = Arc::clone(&self.wait_invoice_is_active); @@ -122,23 +123,45 @@ impl MintPayment for LNbits { msg_option = receiver.recv() => { match msg_option { Some(msg) => { - let check = api.is_invoice_paid(&msg).await; - + let check = api.get_payment_info(&msg).await; match check { - Ok(state) => { - if state { - Some((msg, (api, cancel_token, is_active))) + Ok(payment) => { + if payment.paid { + match hex::decode(msg.clone()) { + Ok(decoded) => { + match decoded.try_into() { + Ok(hash) => { + let response = WaitPaymentResponse { + payment_identifier: PaymentIdentifier::PaymentHash(hash), + payment_amount: Amount::from(payment.details.amount as u64), + unit: CurrencyUnit::Sat, + payment_id: msg.clone() + }; + Some((response, (api, cancel_token, is_active))) + }, + Err(e) => { + tracing::error!("Failed to convert payment hash bytes to array: {:?}", e); + None + } + } + }, + Err(e) => { + tracing::error!("Failed to decode payment hash hex string: {}", e); + None + } + } } else { - Some(("".to_string(), (api, cancel_token, is_active))) + tracing::warn!("Received payment notification but could not check payment for {}", msg); + None } - } - _ => Some(("".to_string(), (api, cancel_token, is_active))), + }, + Err(_) => None } - } + }, None => { is_active.store(false, Ordering::SeqCst); None - }, + } } } } @@ -148,151 +171,181 @@ impl MintPayment for LNbits { async fn get_payment_quote( &self, - request: &str, unit: &CurrencyUnit, - options: Option, + options: OutgoingPaymentOptions, ) -> Result { if unit != &CurrencyUnit::Sat { return Err(Self::Err::Anyhow(anyhow!("Unsupported unit"))); } - let bolt11 = Bolt11Invoice::from_str(request)?; + match options { + OutgoingPaymentOptions::Bolt11(bolt11_options) => { + let amount_msat = match bolt11_options.melt_options { + Some(amount) => { + if matches!(amount, MeltOptions::Mpp { mpp: _ }) { + return Err(payment::Error::UnsupportedPaymentOption); + } + amount.amount_msat() + } + None => bolt11_options + .bolt11 + .amount_milli_satoshis() + .ok_or(Error::UnknownInvoiceAmount)? + .into(), + }; - let amount_msat = match options { - Some(amount) => { - if matches!(amount, MeltOptions::Mpp { mpp: _ }) { - return Err(payment::Error::UnsupportedPaymentOption); - } - amount.amount_msat() + let amount = amount_msat / MSAT_IN_SAT.into(); + + let relative_fee_reserve = + (self.fee_reserve.percent_fee_reserve * u64::from(amount) as f32) as u64; + + let absolute_fee_reserve: u64 = self.fee_reserve.min_fee_reserve.into(); + + let fee = max(relative_fee_reserve, absolute_fee_reserve); + + Ok(PaymentQuoteResponse { + request_lookup_id: PaymentIdentifier::PaymentHash( + *bolt11_options.bolt11.payment_hash().as_ref(), + ), + amount, + fee: fee.into(), + state: MeltQuoteState::Unpaid, + options: None, + unit: unit.clone(), + }) } - None => bolt11 - .amount_milli_satoshis() - .ok_or(Error::UnknownInvoiceAmount)? - .into(), - }; - - let amount = amount_msat / MSAT_IN_SAT.into(); - - let relative_fee_reserve = - (self.fee_reserve.percent_fee_reserve * u64::from(amount) as f32) as u64; - - let absolute_fee_reserve: u64 = self.fee_reserve.min_fee_reserve.into(); - - let fee = max(relative_fee_reserve, absolute_fee_reserve); - - Ok(PaymentQuoteResponse { - request_lookup_id: bolt11.payment_hash().to_string(), - amount, - unit: unit.clone(), - fee: fee.into(), - state: MeltQuoteState::Unpaid, - }) + OutgoingPaymentOptions::Bolt12(_bolt12_options) => { + Err(Self::Err::Anyhow(anyhow!("BOLT12 not supported by LNbits"))) + } + } } async fn make_payment( &self, - melt_quote: mint::MeltQuote, - _partial_msats: Option, - _max_fee_msats: Option, + _unit: &CurrencyUnit, + options: OutgoingPaymentOptions, ) -> Result { - let pay_response = self - .lnbits_api - .pay_invoice(&melt_quote.request, None) - .await - .map_err(|err| { - tracing::error!("Could not pay invoice"); - tracing::error!("{}", err.to_string()); - Self::Err::Anyhow(anyhow!("Could not pay invoice")) - })?; + match options { + OutgoingPaymentOptions::Bolt11(bolt11_options) => { + let pay_response = self + .lnbits_api + .pay_invoice(&bolt11_options.bolt11.to_string(), None) + .await + .map_err(|err| { + tracing::error!("Could not pay invoice"); + tracing::error!("{}", err.to_string()); + Self::Err::Anyhow(anyhow!("Could not pay invoice")) + })?; - let invoice_info = self - .lnbits_api - .get_payment_info(&pay_response.payment_hash) - .await - .map_err(|err| { - tracing::error!("Could not find invoice"); - tracing::error!("{}", err.to_string()); - Self::Err::Anyhow(anyhow!("Could not find invoice")) - })?; + let invoice_info = self + .lnbits_api + .get_payment_info(&pay_response.payment_hash) + .await + .map_err(|err| { + tracing::error!("Could not find invoice"); + tracing::error!("{}", err.to_string()); + Self::Err::Anyhow(anyhow!("Could not find invoice")) + })?; - let status = match invoice_info.paid { - true => MeltQuoteState::Paid, - false => MeltQuoteState::Unpaid, - }; + let status = if invoice_info.paid { + MeltQuoteState::Unpaid + } else { + MeltQuoteState::Paid + }; - let total_spent = Amount::from( - (invoice_info - .details - .amount - .checked_add(invoice_info.details.fee) - .ok_or(Error::AmountOverflow)?) - .unsigned_abs(), - ); + let total_spent = Amount::from( + (invoice_info + .details + .amount + .checked_add(invoice_info.details.fee) + .ok_or(Error::AmountOverflow)?) + .unsigned_abs(), + ); - Ok(MakePaymentResponse { - payment_lookup_id: pay_response.payment_hash, - payment_proof: invoice_info.details.preimage, - status, - total_spent, - unit: CurrencyUnit::Sat, - }) + Ok(MakePaymentResponse { + payment_lookup_id: PaymentIdentifier::PaymentHash( + hex::decode(pay_response.payment_hash) + .map_err(|_| Error::InvalidPaymentHash)? + .try_into() + .map_err(|_| Error::InvalidPaymentHash)?, + ), + payment_proof: Some(invoice_info.details.payment_hash), + status, + total_spent, + unit: CurrencyUnit::Sat, + }) + } + OutgoingPaymentOptions::Bolt12(_) => { + Err(Self::Err::Anyhow(anyhow!("BOLT12 not supported by LNbits"))) + } + } } async fn create_incoming_payment_request( &self, - amount: Amount, unit: &CurrencyUnit, - description: String, - unix_expiry: Option, + options: IncomingPaymentOptions, ) -> Result { if unit != &CurrencyUnit::Sat { return Err(Self::Err::Anyhow(anyhow!("Unsupported unit"))); } - let time_now = unix_time(); + match options { + IncomingPaymentOptions::Bolt11(bolt11_options) => { + let description = bolt11_options.description.unwrap_or_default(); + let amount = bolt11_options.amount; + let unix_expiry = bolt11_options.unix_expiry; - let expiry = unix_expiry.map(|t| t - time_now); + let time_now = unix_time(); + let expiry = unix_expiry.map(|t| t - time_now); - let invoice_request = CreateInvoiceRequest { - amount: to_unit(amount, unit, &CurrencyUnit::Sat)?.into(), - memo: Some(description), - unit: unit.to_string(), - expiry, - webhook: self.webhook_url.clone(), - internal: None, - out: false, - }; + let invoice_request = CreateInvoiceRequest { + amount: to_unit(amount, unit, &CurrencyUnit::Sat)?.into(), + memo: Some(description), + unit: unit.to_string(), + expiry, + webhook: self.webhook_url.clone(), + internal: None, + out: false, + }; - let create_invoice_response = self - .lnbits_api - .create_invoice(&invoice_request) - .await - .map_err(|err| { - tracing::error!("Could not create invoice"); - tracing::error!("{}", err.to_string()); - Self::Err::Anyhow(anyhow!("Could not create invoice")) - })?; + let create_invoice_response = self + .lnbits_api + .create_invoice(&invoice_request) + .await + .map_err(|err| { + tracing::error!("Could not create invoice"); + tracing::error!("{}", err.to_string()); + Self::Err::Anyhow(anyhow!("Could not create invoice")) + })?; - let request: Bolt11Invoice = create_invoice_response - .bolt11() - .ok_or_else(|| Self::Err::Anyhow(anyhow!("Missing bolt11 invoice")))? - .parse()?; - let expiry = request.expires_at().map(|t| t.as_secs()); + let request: Bolt11Invoice = create_invoice_response + .bolt11() + .ok_or_else(|| Self::Err::Anyhow(anyhow!("Missing bolt11 invoice")))? + .parse()?; + let expiry = request.expires_at().map(|t| t.as_secs()); - Ok(CreateIncomingPaymentResponse { - request_lookup_id: create_invoice_response.payment_hash().to_string(), - request: request.to_string(), - expiry, - }) + Ok(CreateIncomingPaymentResponse { + request_lookup_id: PaymentIdentifier::PaymentHash( + *request.payment_hash().as_ref(), + ), + request: request.to_string(), + expiry, + }) + } + IncomingPaymentOptions::Bolt12(_) => { + Err(Self::Err::Anyhow(anyhow!("BOLT12 not supported by LNbits"))) + } + } } async fn check_incoming_payment_status( &self, - payment_hash: &str, - ) -> Result { - let paid = self + payment_identifier: &PaymentIdentifier, + ) -> Result, Self::Err> { + let payment = self .lnbits_api - .is_invoice_paid(payment_hash) + .get_payment_info(&payment_identifier.to_string()) .await .map_err(|err| { tracing::error!("Could not check invoice status"); @@ -300,21 +353,21 @@ impl MintPayment for LNbits { Self::Err::Anyhow(anyhow!("Could not check invoice status")) })?; - let state = match paid { - true => MintQuoteState::Paid, - false => MintQuoteState::Unpaid, - }; - - Ok(state) + Ok(vec![WaitPaymentResponse { + payment_identifier: payment_identifier.clone(), + payment_amount: Amount::from(payment.details.amount as u64), + unit: CurrencyUnit::Sat, + payment_id: payment.details.payment_hash, + }]) } async fn check_outgoing_payment( &self, - payment_hash: &str, + payment_identifier: &PaymentIdentifier, ) -> Result { let payment = self .lnbits_api - .get_payment_info(payment_hash) + .get_payment_info(&payment_identifier.to_string()) .await .map_err(|err| { tracing::error!("Could not check invoice status"); @@ -323,7 +376,7 @@ impl MintPayment for LNbits { })?; let pay_response = MakePaymentResponse { - payment_lookup_id: payment.details.payment_hash, + payment_lookup_id: payment_identifier.clone(), payment_proof: payment.preimage, status: lnbits_to_melt_status(&payment.details.status, payment.details.pending), total_spent: Amount::from( diff --git a/crates/cdk-lnd/src/lib.rs b/crates/cdk-lnd/src/lib.rs index c923d554..98296e94 100644 --- a/crates/cdk-lnd/src/lib.rs +++ b/crates/cdk-lnd/src/lib.rs @@ -18,13 +18,14 @@ use async_trait::async_trait; use cdk_common::amount::{to_unit, Amount, MSAT_IN_SAT}; use cdk_common::bitcoin::hashes::Hash; use cdk_common::common::FeeReserve; -use cdk_common::nuts::{CurrencyUnit, MeltOptions, MeltQuoteState, MintQuoteState}; +use cdk_common::nuts::{CurrencyUnit, MeltOptions, MeltQuoteState}; use cdk_common::payment::{ - self, Bolt11Settings, CreateIncomingPaymentResponse, MakePaymentResponse, MintPayment, - PaymentQuoteResponse, + self, Bolt11Settings, CreateIncomingPaymentResponse, IncomingPaymentOptions, + MakePaymentResponse, MintPayment, OutgoingPaymentOptions, PaymentIdentifier, + PaymentQuoteResponse, WaitPaymentResponse, }; use cdk_common::util::hex; -use cdk_common::{mint, Bolt11Invoice}; +use cdk_common::Bolt11Invoice; use error::Error; use futures::{Stream, StreamExt}; use lnrpc::fee_limit::Limit; @@ -39,6 +40,8 @@ pub mod error; mod proto; pub(crate) use proto::{lnrpc, routerrpc}; +use crate::lnrpc::invoice::InvoiceState; + /// Lnd mint backend #[derive(Clone)] pub struct Lnd { @@ -108,6 +111,7 @@ impl Lnd { unit: CurrencyUnit::Msat, invoice_description: true, amountless: true, + bolt12: false, }, }) } @@ -135,7 +139,7 @@ impl MintPayment for Lnd { #[instrument(skip_all)] async fn wait_any_incoming_payment( &self, - ) -> Result + Send>>, Self::Err> { + ) -> Result + Send>>, Self::Err> { let mut lnd_client = self.lnd_client.clone(); let stream_req = lnrpc::InvoiceSubscription { @@ -176,8 +180,23 @@ impl MintPayment for Lnd { match msg { Ok(Some(msg)) => { - if msg.state == 1 { - Some((hex::encode(msg.r_hash), (stream, cancel_token, is_active))) + if msg.state() == InvoiceState::Settled { + + let hash_slice: Result<[u8;32], _> = msg.r_hash.try_into(); + + if let Ok(hash_slice) = hash_slice { + let hash = hex::encode(hash_slice); + + tracing::info!("LND: Processing payment with hash: {}", hash); + let wait_response = WaitPaymentResponse { + payment_identifier: PaymentIdentifier::PaymentHash(hash_slice), payment_amount: Amount::from(msg.amt_paid_msat as u64), + unit: CurrencyUnit::Msat, + payment_id: hash, + }; + tracing::info!("LND: Created WaitPaymentResponse with amount {} msat", + msg.amt_paid_msat); + Some((wait_response, (stream, cancel_token, is_active))) + } else { None } } else { None } @@ -205,261 +224,299 @@ impl MintPayment for Lnd { #[instrument(skip_all)] async fn get_payment_quote( &self, - request: &str, unit: &CurrencyUnit, - options: Option, + options: OutgoingPaymentOptions, ) -> Result { - let bolt11 = Bolt11Invoice::from_str(request)?; + match options { + OutgoingPaymentOptions::Bolt11(bolt11_options) => { + let amount_msat = match bolt11_options.melt_options { + Some(amount) => amount.amount_msat(), + None => bolt11_options + .bolt11 + .amount_milli_satoshis() + .ok_or(Error::UnknownInvoiceAmount)? + .into(), + }; - let amount_msat = match options { - Some(amount) => amount.amount_msat(), - None => bolt11 - .amount_milli_satoshis() - .ok_or(Error::UnknownInvoiceAmount)? - .into(), - }; + let amount = to_unit(amount_msat, &CurrencyUnit::Msat, unit)?; - let amount = to_unit(amount_msat, &CurrencyUnit::Msat, unit)?; + let relative_fee_reserve = + (self.fee_reserve.percent_fee_reserve * u64::from(amount) as f32) as u64; - let relative_fee_reserve = - (self.fee_reserve.percent_fee_reserve * u64::from(amount) as f32) as u64; + let absolute_fee_reserve: u64 = self.fee_reserve.min_fee_reserve.into(); - let absolute_fee_reserve: u64 = self.fee_reserve.min_fee_reserve.into(); + let fee = max(relative_fee_reserve, absolute_fee_reserve); - let fee = max(relative_fee_reserve, absolute_fee_reserve); - - Ok(PaymentQuoteResponse { - request_lookup_id: bolt11.payment_hash().to_string(), - amount, - unit: unit.clone(), - fee: fee.into(), - state: MeltQuoteState::Unpaid, - }) + Ok(PaymentQuoteResponse { + request_lookup_id: PaymentIdentifier::PaymentHash( + *bolt11_options.bolt11.payment_hash().as_ref(), + ), + amount, + fee: fee.into(), + state: MeltQuoteState::Unpaid, + options: None, + unit: unit.clone(), + }) + } + OutgoingPaymentOptions::Bolt12(_) => { + Err(Self::Err::Anyhow(anyhow!("BOLT12 not supported by LND"))) + } + } } #[instrument(skip_all)] async fn make_payment( &self, - melt_quote: mint::MeltQuote, - partial_amount: Option, - max_fee: Option, + _unit: &CurrencyUnit, + options: OutgoingPaymentOptions, ) -> Result { - let payment_request = melt_quote.request; - let bolt11 = Bolt11Invoice::from_str(&payment_request)?; + match options { + OutgoingPaymentOptions::Bolt11(bolt11_options) => { + let bolt11 = bolt11_options.bolt11; - let pay_state = self - .check_outgoing_payment(&bolt11.payment_hash().to_string()) - .await?; + let pay_state = self + .check_outgoing_payment(&PaymentIdentifier::PaymentHash( + *bolt11.payment_hash().as_ref(), + )) + .await?; - match pay_state.status { - MeltQuoteState::Unpaid | MeltQuoteState::Unknown | MeltQuoteState::Failed => (), - MeltQuoteState::Paid => { - tracing::debug!("Melt attempted on invoice already paid"); - return Err(Self::Err::InvoiceAlreadyPaid); - } - MeltQuoteState::Pending => { - tracing::debug!("Melt attempted on invoice already pending"); - return Err(Self::Err::InvoicePaymentPending); - } - } - - let bolt11 = Bolt11Invoice::from_str(&payment_request)?; - let amount_msat: u64 = match bolt11.amount_milli_satoshis() { - Some(amount_msat) => amount_msat, - None => melt_quote - .msat_to_pay - .ok_or(Error::UnknownInvoiceAmount)? - .into(), - }; - - // Detect partial payments - match partial_amount { - Some(part_amt) => { - let partial_amount_msat = to_unit(part_amt, &melt_quote.unit, &CurrencyUnit::Msat)?; - let invoice = Bolt11Invoice::from_str(&payment_request)?; - - // Extract information from invoice - let pub_key = invoice.get_payee_pub_key(); - let payer_addr = invoice.payment_secret().0.to_vec(); - let payment_hash = invoice.payment_hash(); - - let mut lnd_client = self.lnd_client.clone(); - - for attempt in 0..Self::MAX_ROUTE_RETRIES { - // Create a request for the routes - let route_req = lnrpc::QueryRoutesRequest { - pub_key: hex::encode(pub_key.serialize()), - amt_msat: u64::from(partial_amount_msat) as i64, - fee_limit: max_fee.map(|f| { - let limit = Limit::Fixed(u64::from(f) as i64); - FeeLimit { limit: Some(limit) } - }), - use_mission_control: true, - ..Default::default() - }; - - // Query the routes - let mut routes_response = lnd_client - .lightning() - .query_routes(route_req) - .await - .map_err(Error::LndError)? - .into_inner(); - - // update its MPP record, - // attempt it and check the result - let last_hop: &mut Hop = routes_response.routes[0] - .hops - .last_mut() - .ok_or(Error::MissingLastHop)?; - let mpp_record = MppRecord { - payment_addr: payer_addr.clone(), - total_amt_msat: amount_msat as i64, - }; - last_hop.mpp_record = Some(mpp_record); - - let payment_response = lnd_client - .router() - .send_to_route_v2(routerrpc::SendToRouteRequest { - payment_hash: payment_hash.to_byte_array().to_vec(), - route: Some(routes_response.routes[0].clone()), - ..Default::default() - }) - .await - .map_err(Error::LndError)? - .into_inner(); - - if let Some(failure) = payment_response.failure { - if failure.code == 15 { - tracing::debug!( - "Attempt number {}: route has failed. Re-querying...", - attempt + 1 - ); - continue; - } + match pay_state.status { + MeltQuoteState::Unpaid | MeltQuoteState::Unknown | MeltQuoteState::Failed => (), + MeltQuoteState::Paid => { + tracing::debug!("Melt attempted on invoice already paid"); + return Err(Self::Err::InvoiceAlreadyPaid); } - - // Get status and maybe the preimage - let (status, payment_preimage) = match payment_response.status { - 0 => (MeltQuoteState::Pending, None), - 1 => ( - MeltQuoteState::Paid, - Some(hex::encode(payment_response.preimage)), - ), - 2 => (MeltQuoteState::Unpaid, None), - _ => (MeltQuoteState::Unknown, None), - }; - - // Get the actual amount paid in sats - let mut total_amt: u64 = 0; - if let Some(route) = payment_response.route { - total_amt = (route.total_amt_msat / 1000) as u64; + MeltQuoteState::Pending => { + tracing::debug!("Melt attempted on invoice already pending"); + return Err(Self::Err::InvoicePaymentPending); } - - return Ok(MakePaymentResponse { - payment_lookup_id: hex::encode(payment_hash), - payment_proof: payment_preimage, - status, - total_spent: total_amt.into(), - unit: CurrencyUnit::Sat, - }); } - // "We have exhausted all tactical options" -- STEM, Upgrade (2018) - // The payment was not possible within 50 retries. - tracing::error!("Limit of retries reached, payment couldn't succeed."); - Err(Error::PaymentFailed.into()) + // Detect partial payments + match bolt11_options.melt_options { + Some(MeltOptions::Mpp { mpp }) => { + let amount_msat: u64 = bolt11 + .amount_milli_satoshis() + .ok_or(Error::UnknownInvoiceAmount)?; + { + let partial_amount_msat = mpp.amount; + let invoice = bolt11; + let max_fee: Option = bolt11_options.max_fee_amount; + + // Extract information from invoice + let pub_key = invoice.get_payee_pub_key(); + let payer_addr = invoice.payment_secret().0.to_vec(); + let payment_hash = invoice.payment_hash(); + + let mut lnd_client = self.lnd_client.clone(); + + for attempt in 0..Self::MAX_ROUTE_RETRIES { + // Create a request for the routes + let route_req = lnrpc::QueryRoutesRequest { + pub_key: hex::encode(pub_key.serialize()), + amt_msat: u64::from(partial_amount_msat) as i64, + fee_limit: max_fee.map(|f| { + let limit = Limit::Fixed(u64::from(f) as i64); + FeeLimit { limit: Some(limit) } + }), + use_mission_control: true, + ..Default::default() + }; + + // Query the routes + let mut routes_response = lnd_client + .lightning() + .query_routes(route_req) + .await + .map_err(Error::LndError)? + .into_inner(); + + // update its MPP record, + // attempt it and check the result + let last_hop: &mut Hop = routes_response.routes[0] + .hops + .last_mut() + .ok_or(Error::MissingLastHop)?; + let mpp_record = MppRecord { + payment_addr: payer_addr.clone(), + total_amt_msat: amount_msat as i64, + }; + last_hop.mpp_record = Some(mpp_record); + + let payment_response = lnd_client + .router() + .send_to_route_v2(routerrpc::SendToRouteRequest { + payment_hash: payment_hash.to_byte_array().to_vec(), + route: Some(routes_response.routes[0].clone()), + ..Default::default() + }) + .await + .map_err(Error::LndError)? + .into_inner(); + + if let Some(failure) = payment_response.failure { + if failure.code == 15 { + tracing::debug!( + "Attempt number {}: route has failed. Re-querying...", + attempt + 1 + ); + continue; + } + } + + // Get status and maybe the preimage + let (status, payment_preimage) = match payment_response.status { + 0 => (MeltQuoteState::Pending, None), + 1 => ( + MeltQuoteState::Paid, + Some(hex::encode(payment_response.preimage)), + ), + 2 => (MeltQuoteState::Unpaid, None), + _ => (MeltQuoteState::Unknown, None), + }; + + // Get the actual amount paid in sats + let mut total_amt: u64 = 0; + if let Some(route) = payment_response.route { + total_amt = (route.total_amt_msat / 1000) as u64; + } + + return Ok(MakePaymentResponse { + payment_lookup_id: PaymentIdentifier::PaymentHash( + payment_hash.to_byte_array(), + ), + payment_proof: payment_preimage, + status, + total_spent: total_amt.into(), + unit: CurrencyUnit::Sat, + }); + } + + // "We have exhausted all tactical options" -- STEM, Upgrade (2018) + // The payment was not possible within 50 retries. + tracing::error!("Limit of retries reached, payment couldn't succeed."); + Err(Error::PaymentFailed.into()) + } + } + _ => { + let mut lnd_client = self.lnd_client.clone(); + + let max_fee: Option = bolt11_options.max_fee_amount; + + let amount_msat = u64::from( + bolt11_options + .melt_options + .map(|a| a.amount_msat()) + .unwrap_or_default(), + ); + + let pay_req = lnrpc::SendRequest { + payment_request: bolt11.to_string(), + fee_limit: max_fee.map(|f| { + let limit = Limit::Fixed(u64::from(f) as i64); + FeeLimit { limit: Some(limit) } + }), + amt_msat: amount_msat as i64, + ..Default::default() + }; + + let payment_response = lnd_client + .lightning() + .send_payment_sync(tonic::Request::new(pay_req)) + .await + .map_err(|err| { + tracing::warn!("Lightning payment failed: {}", err); + Error::PaymentFailed + })? + .into_inner(); + + let total_amount = payment_response + .payment_route + .map_or(0, |route| route.total_amt_msat / MSAT_IN_SAT as i64) + as u64; + + let (status, payment_preimage) = match total_amount == 0 { + true => (MeltQuoteState::Unpaid, None), + false => ( + MeltQuoteState::Paid, + Some(hex::encode(payment_response.payment_preimage)), + ), + }; + + let payment_identifier = + PaymentIdentifier::PaymentHash(*bolt11.payment_hash().as_ref()); + + Ok(MakePaymentResponse { + payment_lookup_id: payment_identifier, + payment_proof: payment_preimage, + status, + total_spent: total_amount.into(), + unit: CurrencyUnit::Sat, + }) + } + } } - None => { - let mut lnd_client = self.lnd_client.clone(); - - let pay_req = lnrpc::SendRequest { - payment_request, - fee_limit: max_fee.map(|f| { - let limit = Limit::Fixed(u64::from(f) as i64); - FeeLimit { limit: Some(limit) } - }), - amt_msat: amount_msat as i64, - ..Default::default() - }; - - let payment_response = lnd_client - .lightning() - .send_payment_sync(tonic::Request::new(pay_req)) - .await - .map_err(|err| { - tracing::warn!("Lightning payment failed: {}", err); - Error::PaymentFailed - })? - .into_inner(); - - let total_amount = payment_response - .payment_route - .map_or(0, |route| route.total_amt_msat / MSAT_IN_SAT as i64) - as u64; - - let (status, payment_preimage) = match total_amount == 0 { - true => (MeltQuoteState::Unpaid, None), - false => ( - MeltQuoteState::Paid, - Some(hex::encode(payment_response.payment_preimage)), - ), - }; - - Ok(MakePaymentResponse { - payment_lookup_id: hex::encode(payment_response.payment_hash), - payment_proof: payment_preimage, - status, - total_spent: total_amount.into(), - unit: CurrencyUnit::Sat, - }) + OutgoingPaymentOptions::Bolt12(_) => { + Err(Self::Err::Anyhow(anyhow!("BOLT12 not supported by LND"))) } } } - #[instrument(skip(self, description))] + #[instrument(skip(self, options))] async fn create_incoming_payment_request( &self, - amount: Amount, unit: &CurrencyUnit, - description: String, - unix_expiry: Option, + options: IncomingPaymentOptions, ) -> Result { - let amount = to_unit(amount, unit, &CurrencyUnit::Msat)?; + match options { + IncomingPaymentOptions::Bolt11(bolt11_options) => { + let description = bolt11_options.description.unwrap_or_default(); + let amount = bolt11_options.amount; + let unix_expiry = bolt11_options.unix_expiry; - let invoice_request = lnrpc::Invoice { - value_msat: u64::from(amount) as i64, - memo: description, - ..Default::default() - }; + let amount_msat = to_unit(amount, unit, &CurrencyUnit::Msat)?; - let mut lnd_client = self.lnd_client.clone(); + let invoice_request = lnrpc::Invoice { + value_msat: u64::from(amount_msat) as i64, + memo: description, + ..Default::default() + }; - let invoice = lnd_client - .lightning() - .add_invoice(tonic::Request::new(invoice_request)) - .await - .map_err(|e| payment::Error::Anyhow(anyhow!(e)))? - .into_inner(); + let mut lnd_client = self.lnd_client.clone(); - let bolt11 = Bolt11Invoice::from_str(&invoice.payment_request)?; + let invoice = lnd_client + .lightning() + .add_invoice(tonic::Request::new(invoice_request)) + .await + .map_err(|e| payment::Error::Anyhow(anyhow!(e)))? + .into_inner(); - Ok(CreateIncomingPaymentResponse { - request_lookup_id: bolt11.payment_hash().to_string(), - request: bolt11.to_string(), - expiry: unix_expiry, - }) + let bolt11 = Bolt11Invoice::from_str(&invoice.payment_request)?; + + let payment_identifier = + PaymentIdentifier::PaymentHash(*bolt11.payment_hash().as_ref()); + + Ok(CreateIncomingPaymentResponse { + request_lookup_id: payment_identifier, + request: bolt11.to_string(), + expiry: unix_expiry, + }) + } + IncomingPaymentOptions::Bolt12(_) => { + Err(Self::Err::Anyhow(anyhow!("BOLT12 not supported by LND"))) + } + } } #[instrument(skip(self))] async fn check_incoming_payment_status( &self, - request_lookup_id: &str, - ) -> Result { + payment_identifier: &PaymentIdentifier, + ) -> Result, Self::Err> { let mut lnd_client = self.lnd_client.clone(); let invoice_request = lnrpc::PaymentHash { - r_hash: hex::decode(request_lookup_id).unwrap(), + r_hash: hex::decode(payment_identifier.to_string()).unwrap(), ..Default::default() }; @@ -470,26 +527,27 @@ impl MintPayment for Lnd { .map_err(|e| payment::Error::Anyhow(anyhow!(e)))? .into_inner(); - match invoice.state { - // Open - 0 => Ok(MintQuoteState::Unpaid), - // Settled - 1 => Ok(MintQuoteState::Paid), - // Canceled - 2 => Ok(MintQuoteState::Unpaid), - // Accepted - 3 => Ok(MintQuoteState::Unpaid), - _ => Err(Self::Err::Anyhow(anyhow!("Invalid status"))), + if invoice.state() == InvoiceState::Settled { + Ok(vec![WaitPaymentResponse { + payment_identifier: payment_identifier.clone(), + payment_amount: Amount::from(invoice.amt_paid_msat as u64), + unit: CurrencyUnit::Msat, + payment_id: hex::encode(invoice.r_hash), + }]) + } else { + Ok(vec![]) } } #[instrument(skip(self))] async fn check_outgoing_payment( &self, - payment_hash: &str, + payment_identifier: &PaymentIdentifier, ) -> Result { let mut lnd_client = self.lnd_client.clone(); + let payment_hash = &payment_identifier.to_string(); + let track_request = routerrpc::TrackPaymentRequest { payment_hash: hex::decode(payment_hash).map_err(|_| Error::InvalidHash)?, no_inflight_updates: true, @@ -503,7 +561,7 @@ impl MintPayment for Lnd { let err_code = err.code(); if err_code == tonic::Code::NotFound { return Ok(MakePaymentResponse { - payment_lookup_id: payment_hash.to_string(), + payment_lookup_id: payment_identifier.clone(), payment_proof: None, status: MeltQuoteState::Unknown, total_spent: Amount::ZERO, @@ -522,7 +580,7 @@ impl MintPayment for Lnd { let response = match status { PaymentStatus::Unknown => MakePaymentResponse { - payment_lookup_id: payment_hash.to_string(), + payment_lookup_id: payment_identifier.clone(), payment_proof: Some(update.payment_preimage), status: MeltQuoteState::Unknown, total_spent: Amount::ZERO, @@ -533,7 +591,7 @@ impl MintPayment for Lnd { continue; } PaymentStatus::Succeeded => MakePaymentResponse { - payment_lookup_id: payment_hash.to_string(), + payment_lookup_id: payment_identifier.clone(), payment_proof: Some(update.payment_preimage), status: MeltQuoteState::Paid, total_spent: Amount::from( @@ -546,7 +604,7 @@ impl MintPayment for Lnd { unit: CurrencyUnit::Sat, }, PaymentStatus::Failed => MakePaymentResponse { - payment_lookup_id: payment_hash.to_string(), + payment_lookup_id: payment_identifier.clone(), payment_proof: Some(update.payment_preimage), status: MeltQuoteState::Failed, total_spent: Amount::ZERO, diff --git a/crates/cdk-mint-rpc/Cargo.toml b/crates/cdk-mint-rpc/Cargo.toml index 5b86d9f9..810ac0c0 100644 --- a/crates/cdk-mint-rpc/Cargo.toml +++ b/crates/cdk-mint-rpc/Cargo.toml @@ -23,6 +23,7 @@ anyhow.workspace = true cdk = { workspace = true, features = [ "mint", ] } +cdk-common = { workspace = true } clap.workspace = true tonic = { workspace = true, features = ["transport"] } tracing.workspace = true diff --git a/crates/cdk-mint-rpc/src/proto/server.rs b/crates/cdk-mint-rpc/src/proto/server.rs index f2c03eb5..55fe8f2b 100644 --- a/crates/cdk-mint-rpc/src/proto/server.rs +++ b/crates/cdk-mint-rpc/src/proto/server.rs @@ -3,12 +3,13 @@ use std::path::PathBuf; use std::str::FromStr; use std::sync::Arc; -use cdk::mint::Mint; +use cdk::mint::{Mint, MintQuote}; use cdk::nuts::nut04::MintMethodSettings; use cdk::nuts::nut05::MeltMethodSettings; use cdk::nuts::{CurrencyUnit, MintQuoteState, PaymentMethod}; use cdk::types::QuoteTTL; use cdk::Amount; +use cdk_common::payment::WaitPaymentResponse; use thiserror::Error; use tokio::sync::Notify; use tokio::task::JoinHandle; @@ -650,15 +651,47 @@ impl CdkMint for MintRPCServer { match state { MintQuoteState::Paid => { - self.mint - .pay_mint_quote(&mint_quote) + // Create a dummy payment response + let response = WaitPaymentResponse { + payment_id: String::new(), + payment_amount: mint_quote.amount_paid(), + unit: mint_quote.unit.clone(), + payment_identifier: mint_quote.request_lookup_id.clone(), + }; + + let mut tx = self + .mint + .localstore + .begin_transaction() .await - .map_err(|_| Status::internal("Could not find quote".to_string()))?; + .map_err(|_| Status::internal("Could not start db transaction".to_string()))?; + + self.mint + .pay_mint_quote(&mut tx, &mint_quote, response) + .await + .map_err(|_| Status::internal("Could not process payment".to_string()))?; + + tx.commit() + .await + .map_err(|_| Status::internal("Could not commit db transaction".to_string()))?; } _ => { - let mut mint_quote = mint_quote; - - mint_quote.state = state; + // Create a new quote with the same values + let quote = MintQuote::new( + Some(mint_quote.id), // id + mint_quote.request.clone(), // request + mint_quote.unit.clone(), // unit + mint_quote.amount, // amount + mint_quote.expiry, // expiry + mint_quote.request_lookup_id.clone(), // request_lookup_id + mint_quote.pubkey, // pubkey + mint_quote.amount_issued(), // amount_issued + mint_quote.amount_paid(), // amount_paid + mint_quote.payment_method.clone(), // method + 0, // created_at + vec![], // blinded_messages + vec![], // payment_ids + ); let mut tx = self .mint @@ -666,7 +699,7 @@ impl CdkMint for MintRPCServer { .begin_transaction() .await .map_err(|_| Status::internal("Could not update quote".to_string()))?; - tx.add_or_replace_mint_quote(mint_quote) + tx.add_mint_quote(quote.clone()) .await .map_err(|_| Status::internal("Could not update quote".to_string()))?; tx.commit() @@ -684,7 +717,7 @@ impl CdkMint for MintRPCServer { .ok_or(Status::invalid_argument("Could not find quote".to_string()))?; Ok(Response::new(UpdateNut04QuoteRequest { - state: mint_quote.state.to_string(), + state: mint_quote.state().to_string(), quote_id: mint_quote.id.to_string(), })) } diff --git a/crates/cdk-mintd/src/main.rs b/crates/cdk-mintd/src/main.rs index 36425f1b..85befcf0 100644 --- a/crates/cdk-mintd/src/main.rs +++ b/crates/cdk-mintd/src/main.rs @@ -431,6 +431,21 @@ async fn configure_backend_for_unit( mint_melt_limits: MintMeltLimits, backend: Arc + Send + Sync>, ) -> Result { + let payment_settings = backend.get_settings().await?; + + if let Some(bolt12) = payment_settings.get("bolt12") { + if bolt12.as_bool().unwrap_or_default() { + mint_builder = mint_builder + .add_ln_backend( + unit.clone(), + PaymentMethod::Bolt12, + mint_melt_limits, + Arc::clone(&backend), + ) + .await?; + } + } + mint_builder = mint_builder .add_ln_backend( unit.clone(), @@ -651,39 +666,6 @@ async fn start_services( let listen_port = settings.info.listen_port; let cache: HttpCache = settings.info.http_cache.clone().into(); - let v1_service = - cdk_axum::create_mint_router_with_custom_cache(Arc::clone(&mint), cache).await?; - - let mut mint_service = Router::new() - .merge(v1_service) - .layer( - ServiceBuilder::new() - .layer(RequestDecompressionLayer::new()) - .layer(CompressionLayer::new()), - ) - .layer(TraceLayer::new_for_http()); - - #[cfg(feature = "swagger")] - { - if settings.info.enable_swagger_ui.unwrap_or(false) { - mint_service = mint_service.merge( - utoipa_swagger_ui::SwaggerUi::new("/swagger-ui") - .url("/api-docs/openapi.json", cdk_axum::ApiDocV1::openapi()), - ); - } - } - - for router in ln_routers { - mint_service = mint_service.merge(router); - } - - let shutdown = Arc::new(Notify::new()); - let mint_clone = Arc::clone(&mint); - tokio::spawn({ - let shutdown = Arc::clone(&shutdown); - async move { mint_clone.wait_for_paid_invoices(shutdown).await } - }); - #[cfg(feature = "management-rpc")] let mut rpc_enabled = false; #[cfg(not(feature = "management-rpc"))] @@ -742,6 +724,47 @@ async fn start_services( mint.set_quote_ttl(QuoteTTL::new(10_000, 10_000)).await?; } + let mint_info = mint.mint_info().await?; + let nut04_methods = mint_info.nuts.nut04.supported_methods(); + let nut05_methods = mint_info.nuts.nut05.supported_methods(); + + let bolt12_supported = nut04_methods.contains(&&PaymentMethod::Bolt12) + || nut05_methods.contains(&&PaymentMethod::Bolt12); + + let v1_service = + cdk_axum::create_mint_router_with_custom_cache(Arc::clone(&mint), cache, bolt12_supported) + .await?; + + let mut mint_service = Router::new() + .merge(v1_service) + .layer( + ServiceBuilder::new() + .layer(RequestDecompressionLayer::new()) + .layer(CompressionLayer::new()), + ) + .layer(TraceLayer::new_for_http()); + + #[cfg(feature = "swagger")] + { + if settings.info.enable_swagger_ui.unwrap_or(false) { + mint_service = mint_service.merge( + utoipa_swagger_ui::SwaggerUi::new("/swagger-ui") + .url("/api-docs/openapi.json", cdk_axum::ApiDocV1::openapi()), + ); + } + } + + for router in ln_routers { + mint_service = mint_service.merge(router); + } + + let shutdown = Arc::new(Notify::new()); + let mint_clone = Arc::clone(&mint); + tokio::spawn({ + let shutdown = Arc::clone(&shutdown); + async move { mint_clone.wait_for_paid_invoices(shutdown).await } + }); + let socket_addr = SocketAddr::from_str(&format!("{listen_addr}:{listen_port}"))?; let listener = tokio::net::TcpListener::bind(socket_addr).await?; diff --git a/crates/cdk-payment-processor/Cargo.toml b/crates/cdk-payment-processor/Cargo.toml index ee3bc497..605ff211 100644 --- a/crates/cdk-payment-processor/Cargo.toml +++ b/crates/cdk-payment-processor/Cargo.toml @@ -25,6 +25,7 @@ lnd = ["dep:cdk-lnd"] anyhow.workspace = true async-trait.workspace = true bitcoin.workspace = true +cashu.workspace = true cdk-common = { workspace = true, features = ["mint"] } cdk-cln = { workspace = true, optional = true } cdk-lnd = { workspace = true, optional = true } @@ -34,7 +35,7 @@ thiserror.workspace = true tracing.workspace = true tracing-subscriber.workspace = true lightning-invoice.workspace = true -uuid = { workspace = true, optional = true } +uuid = { workspace = true } utoipa = { workspace = true, optional = true } futures.workspace = true serde_json.workspace = true @@ -43,6 +44,8 @@ tonic = { workspace = true, features = ["router"] } prost.workspace = true tokio-stream.workspace = true tokio-util = { workspace = true, default-features = false } +hex = "0.4" +lightning = { workspace = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] diff --git a/crates/cdk-payment-processor/src/error.rs b/crates/cdk-payment-processor/src/error.rs index a4c27251..6aa90ba0 100644 --- a/crates/cdk-payment-processor/src/error.rs +++ b/crates/cdk-payment-processor/src/error.rs @@ -1,6 +1,7 @@ -//! Errors +//! Error for payment processor use thiserror::Error; +use tonic::Status; /// CDK Payment processor error #[derive(Debug, Error)] @@ -8,13 +9,73 @@ pub enum Error { /// Invalid ID #[error("Invalid id")] InvalidId, + /// Invalid payment identifier + #[error("Invalid payment identifier")] + InvalidPaymentIdentifier, + /// Invalid hash + #[error("Invalid hash")] + InvalidHash, + /// Invalid currency unit + #[error("Invalid currency unit: {0}")] + InvalidCurrencyUnit(String), + /// Parse invoice error + #[error(transparent)] + Invoice(#[from] lightning_invoice::ParseOrSemanticError), + /// Hex decode error + #[error(transparent)] + Hex(#[from] hex::FromHexError), + /// BOLT12 parse error + #[error("BOLT12 parse error")] + Bolt12Parse, /// NUT00 Error #[error(transparent)] NUT00(#[from] cdk_common::nuts::nut00::Error), /// NUT05 error #[error(transparent)] NUT05(#[from] cdk_common::nuts::nut05::Error), - /// Parse invoice error + /// Payment error #[error(transparent)] - Invoice(#[from] lightning_invoice::ParseOrSemanticError), + Payment(#[from] cdk_common::payment::Error), +} + +impl From for Status { + fn from(error: Error) -> Self { + match error { + Error::InvalidId => Status::invalid_argument("Invalid ID"), + Error::InvalidPaymentIdentifier => { + Status::invalid_argument("Invalid payment identifier") + } + Error::InvalidHash => Status::invalid_argument("Invalid hash"), + Error::InvalidCurrencyUnit(unit) => { + Status::invalid_argument(format!("Invalid currency unit: {unit}")) + } + Error::Invoice(err) => Status::invalid_argument(format!("Invoice error: {err}")), + Error::Hex(err) => Status::invalid_argument(format!("Hex decode error: {err}")), + Error::Bolt12Parse => Status::invalid_argument("BOLT12 parse error"), + Error::NUT00(err) => Status::internal(format!("NUT00 error: {err}")), + Error::NUT05(err) => Status::internal(format!("NUT05 error: {err}")), + Error::Payment(err) => Status::internal(format!("Payment error: {err}")), + } + } +} + +impl From for cdk_common::payment::Error { + fn from(error: Error) -> Self { + match error { + Error::InvalidId => Self::Custom("Invalid ID".to_string()), + Error::InvalidPaymentIdentifier => { + Self::Custom("Invalid payment identifier".to_string()) + } + Error::InvalidHash => Self::Custom("Invalid hash".to_string()), + Error::InvalidCurrencyUnit(unit) => { + Self::Custom(format!("Invalid currency unit: {unit}")) + } + Error::Invoice(err) => Self::Custom(format!("Invoice error: {err}")), + Error::Hex(err) => Self::Custom(format!("Hex decode error: {err}")), + Error::Bolt12Parse => Self::Custom("BOLT12 parse error".to_string()), + Error::NUT00(err) => Self::Custom(format!("NUT00 error: {err}")), + Error::NUT05(err) => err.into(), + Error::Payment(err) => err, + } + } } diff --git a/crates/cdk-payment-processor/src/lib.rs b/crates/cdk-payment-processor/src/lib.rs index 89ff00ca..31b39a00 100644 --- a/crates/cdk-payment-processor/src/lib.rs +++ b/crates/cdk-payment-processor/src/lib.rs @@ -3,6 +3,7 @@ #![warn(rustdoc::bare_urls)] pub mod error; +/// Protocol types and functionality for the CDK payment processor pub mod proto; pub use proto::cdk_payment_processor_client::CdkPaymentProcessorClient; diff --git a/crates/cdk-payment-processor/src/proto/client.rs b/crates/cdk-payment-processor/src/proto/client.rs index ab9292aa..58b477ee 100644 --- a/crates/cdk-payment-processor/src/proto/client.rs +++ b/crates/cdk-payment-processor/src/proto/client.rs @@ -1,15 +1,14 @@ use std::path::PathBuf; use std::pin::Pin; -use std::str::FromStr; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use anyhow::anyhow; use cdk_common::payment::{ - CreateIncomingPaymentResponse, MakePaymentResponse as CdkMakePaymentResponse, MintPayment, - PaymentQuoteResponse, + CreateIncomingPaymentResponse, IncomingPaymentOptions as CdkIncomingPaymentOptions, + MakePaymentResponse as CdkMakePaymentResponse, MintPayment, + PaymentQuoteResponse as CdkPaymentQuoteResponse, WaitPaymentResponse, }; -use cdk_common::{mint, Amount, CurrencyUnit, MeltOptions, MintQuoteState}; use futures::{Stream, StreamExt}; use serde_json::Value; use tokio_util::sync::CancellationToken; @@ -17,10 +16,10 @@ use tonic::transport::{Certificate, Channel, ClientTlsConfig, Identity}; use tonic::{async_trait, Request}; use tracing::instrument; -use super::cdk_payment_processor_client::CdkPaymentProcessorClient; -use super::{ - CheckIncomingPaymentRequest, CheckOutgoingPaymentRequest, CreatePaymentRequest, - MakePaymentRequest, SettingsRequest, WaitIncomingPaymentRequest, +use crate::proto::cdk_payment_processor_client::CdkPaymentProcessorClient; +use crate::proto::{ + CheckIncomingPaymentRequest, CheckOutgoingPaymentRequest, CreatePaymentRequest, EmptyRequest, + IncomingPaymentOptions, MakePaymentRequest, OutgoingPaymentRequestType, PaymentQuoteRequest, }; /// Payment Processor @@ -100,7 +99,7 @@ impl MintPayment for PaymentProcessorClient { async fn get_settings(&self) -> Result { let mut inner = self.inner.clone(); let response = inner - .get_settings(Request::new(SettingsRequest {})) + .get_settings(Request::new(EmptyRequest {})) .await .map_err(|err| { tracing::error!("Could not get settings: {}", err); @@ -115,18 +114,36 @@ impl MintPayment for PaymentProcessorClient { /// Create a new invoice async fn create_incoming_payment_request( &self, - amount: Amount, - unit: &CurrencyUnit, - description: String, - unix_expiry: Option, + unit: &cdk_common::CurrencyUnit, + options: CdkIncomingPaymentOptions, ) -> Result { let mut inner = self.inner.clone(); + + let proto_options = match options { + CdkIncomingPaymentOptions::Bolt11(opts) => IncomingPaymentOptions { + options: Some(super::incoming_payment_options::Options::Bolt11( + super::Bolt11IncomingPaymentOptions { + description: opts.description, + amount: opts.amount.into(), + unix_expiry: opts.unix_expiry, + }, + )), + }, + CdkIncomingPaymentOptions::Bolt12(opts) => IncomingPaymentOptions { + options: Some(super::incoming_payment_options::Options::Bolt12( + super::Bolt12IncomingPaymentOptions { + description: opts.description, + amount: opts.amount.map(Into::into), + unix_expiry: opts.unix_expiry, + }, + )), + }, + }; + let response = inner .create_payment(Request::new(CreatePaymentRequest { - amount: amount.into(), unit: unit.to_string(), - description, - unix_expiry, + options: Some(proto_options), })) .await .map_err(|err| { @@ -143,16 +160,36 @@ impl MintPayment for PaymentProcessorClient { async fn get_payment_quote( &self, - request: &str, - unit: &CurrencyUnit, - options: Option, - ) -> Result { + unit: &cdk_common::CurrencyUnit, + options: cdk_common::payment::OutgoingPaymentOptions, + ) -> Result { let mut inner = self.inner.clone(); + + let request_type = match &options { + cdk_common::payment::OutgoingPaymentOptions::Bolt11(_) => { + OutgoingPaymentRequestType::Bolt11Invoice + } + cdk_common::payment::OutgoingPaymentOptions::Bolt12(_) => { + OutgoingPaymentRequestType::Bolt12Offer + } + }; + + let proto_request = match &options { + cdk_common::payment::OutgoingPaymentOptions::Bolt11(opts) => opts.bolt11.to_string(), + cdk_common::payment::OutgoingPaymentOptions::Bolt12(opts) => opts.offer.to_string(), + }; + + let proto_options = match &options { + cdk_common::payment::OutgoingPaymentOptions::Bolt11(opts) => opts.melt_options, + cdk_common::payment::OutgoingPaymentOptions::Bolt12(opts) => opts.melt_options, + }; + let response = inner - .get_payment_quote(Request::new(super::PaymentQuoteRequest { - request: request.to_string(), + .get_payment_quote(Request::new(PaymentQuoteRequest { + request: proto_request, unit: unit.to_string(), - options: options.map(|o| o.into()), + options: proto_options.map(Into::into), + request_type: request_type.into(), })) .await .map_err(|err| { @@ -167,16 +204,44 @@ impl MintPayment for PaymentProcessorClient { async fn make_payment( &self, - melt_quote: mint::MeltQuote, - partial_amount: Option, - max_fee_amount: Option, + _unit: &cdk_common::CurrencyUnit, + options: cdk_common::payment::OutgoingPaymentOptions, ) -> Result { let mut inner = self.inner.clone(); + + let payment_options = match options { + cdk_common::payment::OutgoingPaymentOptions::Bolt11(opts) => { + super::OutgoingPaymentVariant { + options: Some(super::outgoing_payment_variant::Options::Bolt11( + super::Bolt11OutgoingPaymentOptions { + bolt11: opts.bolt11.to_string(), + max_fee_amount: opts.max_fee_amount.map(Into::into), + timeout_secs: opts.timeout_secs, + melt_options: opts.melt_options.map(Into::into), + }, + )), + } + } + cdk_common::payment::OutgoingPaymentOptions::Bolt12(opts) => { + super::OutgoingPaymentVariant { + options: Some(super::outgoing_payment_variant::Options::Bolt12( + super::Bolt12OutgoingPaymentOptions { + offer: opts.offer.to_string(), + max_fee_amount: opts.max_fee_amount.map(Into::into), + timeout_secs: opts.timeout_secs, + invoice: opts.invoice, + melt_options: opts.melt_options.map(Into::into), + }, + )), + } + } + }; + let response = inner .make_payment(Request::new(MakePaymentRequest { - melt_quote: Some(melt_quote.into()), - partial_amount: partial_amount.map(|a| a.into()), - max_fee_amount: max_fee_amount.map(|a| a.into()), + payment_options: Some(payment_options), + partial_amount: None, + max_fee_amount: None, })) .await .map_err(|err| { @@ -198,17 +263,16 @@ impl MintPayment for PaymentProcessorClient { })?) } - /// Listen for invoices to be paid to the mint #[instrument(skip_all)] async fn wait_any_incoming_payment( &self, - ) -> Result + Send>>, Self::Err> { + ) -> Result + Send>>, Self::Err> { self.wait_incoming_payment_stream_is_active .store(true, Ordering::SeqCst); tracing::debug!("Client waiting for payment"); let mut inner = self.inner.clone(); let stream = inner - .wait_incoming_payment(WaitIncomingPaymentRequest {}) + .wait_incoming_payment(EmptyRequest {}) .await .map_err(|err| { tracing::error!("Could not check incoming payment stream: {}", err); @@ -222,15 +286,18 @@ impl MintPayment for PaymentProcessorClient { let transformed_stream = stream .take_until(cancel_fut) - .filter_map(|item| async move { + .filter_map(|item| async { match item { - Ok(value) => { - tracing::warn!("{}", value.lookup_id); - Some(value.lookup_id) - } + Ok(value) => match value.try_into() { + Ok(payment_response) => Some(payment_response), + Err(e) => { + tracing::error!("Error converting payment response: {}", e); + None + } + }, Err(e) => { tracing::error!("Error in payment stream: {}", e); - None // Skip this item and continue with the stream + None } } }) @@ -255,12 +322,12 @@ impl MintPayment for PaymentProcessorClient { async fn check_incoming_payment_status( &self, - request_lookup_id: &str, - ) -> Result { + payment_identifier: &cdk_common::payment::PaymentIdentifier, + ) -> Result, Self::Err> { let mut inner = self.inner.clone(); let response = inner .check_incoming_payment(Request::new(CheckIncomingPaymentRequest { - request_lookup_id: request_lookup_id.to_string(), + request_identifier: Some(payment_identifier.clone().into()), })) .await .map_err(|err| { @@ -269,20 +336,21 @@ impl MintPayment for PaymentProcessorClient { })?; let check_incoming = response.into_inner(); - - let status = check_incoming.status().as_str_name(); - - Ok(MintQuoteState::from_str(status)?) + check_incoming + .payments + .into_iter() + .map(|resp| resp.try_into().map_err(Self::Err::from)) + .collect() } async fn check_outgoing_payment( &self, - request_lookup_id: &str, + payment_identifier: &cdk_common::payment::PaymentIdentifier, ) -> Result { let mut inner = self.inner.clone(); let response = inner .check_outgoing_payment(Request::new(CheckOutgoingPaymentRequest { - request_lookup_id: request_lookup_id.to_string(), + request_identifier: Some(payment_identifier.clone().into()), })) .await .map_err(|err| { diff --git a/crates/cdk-payment-processor/src/proto/mod.rs b/crates/cdk-payment-processor/src/proto/mod.rs index 95c6e819..d7788a14 100644 --- a/crates/cdk-payment-processor/src/proto/mod.rs +++ b/crates/cdk-payment-processor/src/proto/mod.rs @@ -1,12 +1,11 @@ -//! Proto types for payment processor - use std::str::FromStr; use cdk_common::payment::{ CreateIncomingPaymentResponse, MakePaymentResponse as CdkMakePaymentResponse, + PaymentIdentifier as CdkPaymentIdentifier, WaitPaymentResponse, }; -use cdk_common::{Bolt11Invoice, CurrencyUnit, MeltQuoteBolt11Request}; -use melt_options::Options; +use cdk_common::{CurrencyUnit, MeltOptions as CdkMeltOptions}; + mod client; mod server; @@ -15,15 +14,85 @@ pub use server::PaymentProcessorServer; tonic::include_proto!("cdk_payment_processor"); +impl From for PaymentIdentifier { + fn from(value: CdkPaymentIdentifier) -> Self { + match value { + CdkPaymentIdentifier::Label(id) => Self { + r#type: PaymentIdentifierType::Label.into(), + value: Some(payment_identifier::Value::Id(id)), + }, + CdkPaymentIdentifier::OfferId(id) => Self { + r#type: PaymentIdentifierType::OfferId.into(), + value: Some(payment_identifier::Value::Id(id)), + }, + CdkPaymentIdentifier::PaymentHash(hash) => Self { + r#type: PaymentIdentifierType::PaymentHash.into(), + value: Some(payment_identifier::Value::Hash(hex::encode(hash))), + }, + CdkPaymentIdentifier::Bolt12PaymentHash(hash) => Self { + r#type: PaymentIdentifierType::Bolt12PaymentHash.into(), + value: Some(payment_identifier::Value::Hash(hex::encode(hash))), + }, + CdkPaymentIdentifier::CustomId(id) => Self { + r#type: PaymentIdentifierType::CustomId.into(), + value: Some(payment_identifier::Value::Id(id)), + }, + } + } +} + +impl TryFrom for CdkPaymentIdentifier { + type Error = crate::error::Error; + + fn try_from(value: PaymentIdentifier) -> Result { + match (value.r#type(), value.value) { + (PaymentIdentifierType::Label, Some(payment_identifier::Value::Id(id))) => { + Ok(CdkPaymentIdentifier::Label(id)) + } + (PaymentIdentifierType::OfferId, Some(payment_identifier::Value::Id(id))) => { + Ok(CdkPaymentIdentifier::OfferId(id)) + } + (PaymentIdentifierType::PaymentHash, Some(payment_identifier::Value::Hash(hash))) => { + let decoded = hex::decode(hash)?; + let hash_array: [u8; 32] = decoded + .try_into() + .map_err(|_| crate::error::Error::InvalidHash)?; + Ok(CdkPaymentIdentifier::PaymentHash(hash_array)) + } + ( + PaymentIdentifierType::Bolt12PaymentHash, + Some(payment_identifier::Value::Hash(hash)), + ) => { + let decoded = hex::decode(hash)?; + let hash_array: [u8; 32] = decoded + .try_into() + .map_err(|_| crate::error::Error::InvalidHash)?; + Ok(CdkPaymentIdentifier::Bolt12PaymentHash(hash_array)) + } + (PaymentIdentifierType::CustomId, Some(payment_identifier::Value::Id(id))) => { + Ok(CdkPaymentIdentifier::CustomId(id)) + } + _ => Err(crate::error::Error::InvalidPaymentIdentifier), + } + } +} + impl TryFrom for CdkMakePaymentResponse { type Error = crate::error::Error; fn try_from(value: MakePaymentResponse) -> Result { + let status = value.status().as_str_name().parse()?; + let payment_proof = value.payment_proof; + let total_spent = value.total_spent.into(); + let unit = CurrencyUnit::from_str(&value.unit)?; + let payment_identifier = value + .payment_identifier + .ok_or(crate::error::Error::InvalidPaymentIdentifier)?; Ok(Self { - payment_lookup_id: value.payment_lookup_id.clone(), - payment_proof: value.payment_proof.clone(), - status: value.status().as_str_name().parse()?, - total_spent: value.total_spent.into(), - unit: value.unit.parse()?, + payment_lookup_id: payment_identifier.try_into()?, + payment_proof, + status, + total_spent, + unit, }) } } @@ -31,8 +100,8 @@ impl TryFrom for CdkMakePaymentResponse { impl From for MakePaymentResponse { fn from(value: CdkMakePaymentResponse) -> Self { Self { - payment_lookup_id: value.payment_lookup_id.clone(), - payment_proof: value.payment_proof.clone(), + payment_identifier: Some(value.payment_lookup_id.into()), + payment_proof: value.payment_proof, status: QuoteState::from(value.status).into(), total_spent: value.total_spent.into(), unit: value.unit.to_string(), @@ -43,8 +112,8 @@ impl From for MakePaymentResponse { impl From for CreatePaymentResponse { fn from(value: CreateIncomingPaymentResponse) -> Self { Self { - request_lookup_id: value.request_lookup_id, - request: value.request.to_string(), + request_identifier: Some(value.request_lookup_id.into()), + request: value.request, expiry: value.expiry, } } @@ -54,82 +123,102 @@ impl TryFrom for CreateIncomingPaymentResponse { type Error = crate::error::Error; fn try_from(value: CreatePaymentResponse) -> Result { + let request_identifier = value + .request_identifier + .ok_or(crate::error::Error::InvalidPaymentIdentifier)?; Ok(Self { - request_lookup_id: value.request_lookup_id, + request_lookup_id: request_identifier.try_into()?, request: value.request, expiry: value.expiry, }) } } -impl From<&MeltQuoteBolt11Request> for PaymentQuoteRequest { - fn from(value: &MeltQuoteBolt11Request) -> Self { - Self { - request: value.request.to_string(), - unit: value.unit.to_string(), - options: value.options.map(|o| o.into()), - } - } -} - impl From for PaymentQuoteResponse { fn from(value: cdk_common::payment::PaymentQuoteResponse) -> Self { Self { - request_lookup_id: value.request_lookup_id, + request_identifier: Some(value.request_lookup_id.into()), amount: value.amount.into(), fee: value.fee.into(), - state: QuoteState::from(value.state).into(), unit: value.unit.to_string(), - } - } -} - -impl From for MeltOptions { - fn from(value: cdk_common::nut23::MeltOptions) -> Self { - Self { - options: Some(value.into()), - } - } -} - -impl From for Options { - fn from(value: cdk_common::nut23::MeltOptions) -> Self { - match value { - cdk_common::MeltOptions::Mpp { mpp } => Self::Mpp(Mpp { - amount: mpp.amount.into(), + state: QuoteState::from(value.state).into(), + melt_options: value.options.map(|opt| match opt { + cdk_common::payment::PaymentQuoteOptions::Bolt12 { invoice } => { + PaymentQuoteOptions { + melt_options: Some(payment_quote_options::MeltOptions::Bolt12( + Bolt12Options { + invoice: invoice.map(String::from_utf8).and_then(|r| r.ok()), + }, + )), + } + } }), - cdk_common::MeltOptions::Amountless { amountless } => Self::Amountless(Amountless { - amount_msat: amountless.amount_msat.into(), - }), - } - } -} - -impl From for cdk_common::nut23::MeltOptions { - fn from(value: MeltOptions) -> Self { - let options = value.options.expect("option defined"); - match options { - Options::Mpp(mpp) => cdk_common::MeltOptions::new_mpp(mpp.amount), - Options::Amountless(amountless) => { - cdk_common::MeltOptions::new_amountless(amountless.amount_msat) - } } } } impl From for cdk_common::payment::PaymentQuoteResponse { fn from(value: PaymentQuoteResponse) -> Self { + let state_val = value.state(); + let request_identifier = value + .request_identifier + .expect("request identifier required"); + Self { - request_lookup_id: value.request_lookup_id.clone(), + request_lookup_id: request_identifier + .try_into() + .expect("valid request identifier"), amount: value.amount.into(), - unit: CurrencyUnit::from_str(&value.unit).unwrap_or_default(), fee: value.fee.into(), - state: value.state().into(), + unit: CurrencyUnit::from_str(&value.unit).unwrap_or_default(), + state: state_val.into(), + options: value.melt_options.map(|opt| match opt.melt_options { + Some(payment_quote_options::MeltOptions::Bolt12(bolt12)) => { + cdk_common::payment::PaymentQuoteOptions::Bolt12 { + invoice: bolt12.invoice.as_deref().map(str::as_bytes).map(Vec::from), + } + } + None => unreachable!(), + }), } } } -impl From for cdk_common::nut05::QuoteState { +impl From for CdkMeltOptions { + fn from(value: MeltOptions) -> Self { + match value.options.expect("option defined") { + melt_options::Options::Mpp(mpp) => Self::Mpp { + mpp: cashu::nuts::nut15::Mpp { + amount: mpp.amount.into(), + }, + }, + melt_options::Options::Amountless(amountless) => Self::Amountless { + amountless: cashu::nuts::nut23::Amountless { + amount_msat: amountless.amount_msat.into(), + }, + }, + } + } +} + +impl From for MeltOptions { + fn from(value: CdkMeltOptions) -> Self { + match value { + CdkMeltOptions::Mpp { mpp } => Self { + options: Some(melt_options::Options::Mpp(Mpp { + amount: mpp.amount.into(), + })), + }, + CdkMeltOptions::Amountless { amountless } => Self { + options: Some(melt_options::Options::Amountless(Amountless { + amount_msat: amountless.amount_msat.into(), + })), + }, + } + } +} + +impl From for cdk_common::nuts::MeltQuoteState { fn from(value: QuoteState) -> Self { match value { QuoteState::Unpaid => Self::Unpaid, @@ -142,80 +231,53 @@ impl From for cdk_common::nut05::QuoteState { } } -impl From for QuoteState { - fn from(value: cdk_common::nut05::QuoteState) -> Self { +impl From for QuoteState { + fn from(value: cdk_common::nuts::MeltQuoteState) -> Self { match value { - cdk_common::MeltQuoteState::Unpaid => Self::Unpaid, - cdk_common::MeltQuoteState::Paid => Self::Paid, - cdk_common::MeltQuoteState::Pending => Self::Pending, - cdk_common::MeltQuoteState::Unknown => Self::Unknown, - cdk_common::MeltQuoteState::Failed => Self::Failed, + cdk_common::nuts::MeltQuoteState::Unpaid => Self::Unpaid, + cdk_common::nuts::MeltQuoteState::Paid => Self::Paid, + cdk_common::nuts::MeltQuoteState::Pending => Self::Pending, + cdk_common::nuts::MeltQuoteState::Unknown => Self::Unknown, + cdk_common::nuts::MeltQuoteState::Failed => Self::Failed, } } } -impl From for QuoteState { - fn from(value: cdk_common::nut23::QuoteState) -> Self { +impl From for QuoteState { + fn from(value: cdk_common::nuts::MintQuoteState) -> Self { match value { - cdk_common::MintQuoteState::Unpaid => Self::Unpaid, - cdk_common::MintQuoteState::Paid => Self::Paid, - cdk_common::MintQuoteState::Pending => Self::Pending, - cdk_common::MintQuoteState::Issued => Self::Issued, + cdk_common::nuts::MintQuoteState::Unpaid => Self::Unpaid, + cdk_common::nuts::MintQuoteState::Paid => Self::Paid, + cdk_common::nuts::MintQuoteState::Issued => Self::Issued, } } } -impl From for MeltQuote { - fn from(value: cdk_common::mint::MeltQuote) -> Self { +impl From for WaitIncomingPaymentResponse { + fn from(value: WaitPaymentResponse) -> Self { Self { - id: value.id.to_string(), + payment_identifier: Some(value.payment_identifier.into()), + payment_amount: value.payment_amount.into(), unit: value.unit.to_string(), - amount: value.amount.into(), - request: value.request, - fee_reserve: value.fee_reserve.into(), - state: QuoteState::from(value.state).into(), - expiry: value.expiry, - payment_preimage: value.payment_preimage, - request_lookup_id: value.request_lookup_id, - msat_to_pay: value.msat_to_pay.map(|a| a.into()), - created_time: value.created_time, - paid_time: value.paid_time, + payment_id: value.payment_id, } } } -impl TryFrom for cdk_common::mint::MeltQuote { +impl TryFrom for WaitPaymentResponse { type Error = crate::error::Error; - fn try_from(value: MeltQuote) -> Result { - Ok(Self { - id: value - .id - .parse() - .map_err(|_| crate::error::Error::InvalidId)?, - unit: value.unit.parse()?, - amount: value.amount.into(), - request: value.request.clone(), - fee_reserve: value.fee_reserve.into(), - state: cdk_common::nut05::QuoteState::from(value.state()), - expiry: value.expiry, - payment_preimage: value.payment_preimage, - request_lookup_id: value.request_lookup_id, - msat_to_pay: value.msat_to_pay.map(|a| a.into()), - created_time: value.created_time, - paid_time: value.paid_time, - }) - } -} + fn try_from(value: WaitIncomingPaymentResponse) -> Result { + let payment_identifier = value + .payment_identifier + .ok_or(crate::error::Error::InvalidPaymentIdentifier)? + .try_into()?; -impl TryFrom for MeltQuoteBolt11Request { - type Error = crate::error::Error; - - fn try_from(value: PaymentQuoteRequest) -> Result { Ok(Self { - request: Bolt11Invoice::from_str(&value.request)?, + payment_identifier, + payment_amount: value.payment_amount.into(), unit: CurrencyUnit::from_str(&value.unit)?, - options: value.options.map(|o| o.into()), + payment_id: value.payment_id, }) } } diff --git a/crates/cdk-payment-processor/src/proto/payment_processor.proto b/crates/cdk-payment-processor/src/proto/payment_processor.proto index 94a6021c..7a32187d 100644 --- a/crates/cdk-payment-processor/src/proto/payment_processor.proto +++ b/crates/cdk-payment-processor/src/proto/payment_processor.proto @@ -3,30 +3,73 @@ syntax = "proto3"; package cdk_payment_processor; service CdkPaymentProcessor { - rpc GetSettings(SettingsRequest) returns (SettingsResponse) {} + rpc GetSettings(EmptyRequest) returns (SettingsResponse) {} rpc CreatePayment(CreatePaymentRequest) returns (CreatePaymentResponse) {} rpc GetPaymentQuote(PaymentQuoteRequest) returns (PaymentQuoteResponse) {} rpc MakePayment(MakePaymentRequest) returns (MakePaymentResponse) {} rpc CheckIncomingPayment(CheckIncomingPaymentRequest) returns (CheckIncomingPaymentResponse) {} rpc CheckOutgoingPayment(CheckOutgoingPaymentRequest) returns (MakePaymentResponse) {} - rpc WaitIncomingPayment(WaitIncomingPaymentRequest) returns (stream WaitIncomingPaymentResponse) {} + rpc WaitIncomingPayment(EmptyRequest) returns (stream WaitIncomingPaymentResponse) {} } -message SettingsRequest {} +message EmptyRequest {} message SettingsResponse { string inner = 1; } +message Bolt11IncomingPaymentOptions { + optional string description = 1; + uint64 amount = 2; + optional uint64 unix_expiry = 3; +} + +message Bolt12IncomingPaymentOptions { + optional string description = 1; + optional uint64 amount = 2; + optional uint64 unix_expiry = 3; +} + +enum PaymentMethodType { + BOLT11 = 0; + BOLT12 = 1; +} + +enum OutgoingPaymentRequestType { + BOLT11_INVOICE = 0; + BOLT12_OFFER = 1; +} + +enum PaymentIdentifierType { + PAYMENT_HASH = 0; + OFFER_ID = 1; + LABEL = 2; + BOLT12_PAYMENT_HASH = 3; + CUSTOM_ID = 4; +} + +message PaymentIdentifier { + PaymentIdentifierType type = 1; + oneof value { + string hash = 2; // Used for PAYMENT_HASH and BOLT12_PAYMENT_HASH + string id = 3; // Used for OFFER_ID, LABEL, and CUSTOM_ID + } +} + +message IncomingPaymentOptions { + oneof options { + Bolt11IncomingPaymentOptions bolt11 = 1; + Bolt12IncomingPaymentOptions bolt12 = 2; + } +} + message CreatePaymentRequest { - uint64 amount = 1; - string unit = 2; - string description = 3; - optional uint64 unix_expiry = 4; + string unit = 1; + IncomingPaymentOptions options = 2; } message CreatePaymentResponse { - string request_lookup_id = 1; + PaymentIdentifier request_identifier = 1; string request = 2; optional uint64 expiry = 3; } @@ -35,7 +78,6 @@ message Mpp { uint64 amount = 1; } - message Amountless { uint64 amount_msat = 1; } @@ -51,6 +93,7 @@ message PaymentQuoteRequest { string request = 1; string unit = 2; optional MeltOptions options = 3; + OutgoingPaymentRequestType request_type = 4; } enum QuoteState { @@ -62,38 +105,60 @@ enum QuoteState { ISSUED = 5; } +message Bolt12Options { + optional string invoice = 1; +} + +message PaymentQuoteOptions { + oneof melt_options { + Bolt12Options bolt12 = 1; + } +} message PaymentQuoteResponse { - string request_lookup_id = 1; + PaymentIdentifier request_identifier = 1; uint64 amount = 2; uint64 fee = 3; QuoteState state = 4; - string unit = 5; + optional PaymentQuoteOptions melt_options = 5; + string unit = 6; } -message MeltQuote { - string id = 1; - string unit = 2; - uint64 amount = 3; - string request = 4; - uint64 fee_reserve = 5; - QuoteState state = 6; - uint64 expiry = 7; - optional string payment_preimage = 8; - string request_lookup_id = 9; - optional uint64 msat_to_pay = 10; - uint64 created_time = 11; - optional uint64 paid_time = 12; +message Bolt11OutgoingPaymentOptions { + string bolt11 = 1; + optional uint64 max_fee_amount = 2; + optional uint64 timeout_secs = 3; + optional MeltOptions melt_options = 4; +} + +message Bolt12OutgoingPaymentOptions { + string offer = 1; + optional uint64 max_fee_amount = 2; + optional uint64 timeout_secs = 3; + optional bytes invoice = 4; + optional MeltOptions melt_options = 5; +} + +enum OutgoingPaymentOptionsType { + OUTGOING_BOLT11 = 0; + OUTGOING_BOLT12 = 1; +} + +message OutgoingPaymentVariant { + oneof options { + Bolt11OutgoingPaymentOptions bolt11 = 1; + Bolt12OutgoingPaymentOptions bolt12 = 2; + } } message MakePaymentRequest { - MeltQuote melt_quote = 1; + OutgoingPaymentVariant payment_options = 1; optional uint64 partial_amount = 2; optional uint64 max_fee_amount = 3; } message MakePaymentResponse { - string payment_lookup_id = 1; + PaymentIdentifier payment_identifier = 1; optional string payment_proof = 2; QuoteState status = 3; uint64 total_spent = 4; @@ -101,22 +166,20 @@ message MakePaymentResponse { } message CheckIncomingPaymentRequest { - string request_lookup_id = 1; + PaymentIdentifier request_identifier = 1; } message CheckIncomingPaymentResponse { - QuoteState status = 1; + repeated WaitIncomingPaymentResponse payments = 1; } message CheckOutgoingPaymentRequest { - string request_lookup_id = 1; + PaymentIdentifier request_identifier = 1; } - -message WaitIncomingPaymentRequest { -} - - message WaitIncomingPaymentResponse { - string lookup_id = 1; + PaymentIdentifier payment_identifier = 1; + uint64 payment_amount = 2; + string unit = 3; + string payment_id = 4; } diff --git a/crates/cdk-payment-processor/src/proto/server.rs b/crates/cdk-payment-processor/src/proto/server.rs index 823b73c3..d983cf44 100644 --- a/crates/cdk-payment-processor/src/proto/server.rs +++ b/crates/cdk-payment-processor/src/proto/server.rs @@ -5,8 +5,10 @@ use std::str::FromStr; use std::sync::Arc; use std::time::Duration; -use cdk_common::payment::MintPayment; +use cdk_common::payment::{IncomingPaymentOptions, MintPayment}; +use cdk_common::CurrencyUnit; use futures::{Stream, StreamExt}; +use lightning::offers::offer::Offer; use serde_json::Value; use tokio::sync::{mpsc, Notify}; use tokio::task::JoinHandle; @@ -17,6 +19,7 @@ use tonic::{async_trait, Request, Response, Status}; use tracing::instrument; use super::cdk_payment_processor_server::{CdkPaymentProcessor, CdkPaymentProcessorServer}; +use crate::error::Error; use crate::proto::*; type ResponseStream = @@ -162,7 +165,7 @@ impl Drop for PaymentProcessorServer { impl CdkPaymentProcessor for PaymentProcessorServer { async fn get_settings( &self, - _request: Request, + _request: Request, ) -> Result, Status> { let settings: Value = self .inner @@ -179,18 +182,36 @@ impl CdkPaymentProcessor for PaymentProcessorServer { &self, request: Request, ) -> Result, Status> { - let CreatePaymentRequest { - amount, - unit, - description, - unix_expiry, - } = request.into_inner(); + let CreatePaymentRequest { unit, options } = request.into_inner(); + + let unit = CurrencyUnit::from_str(&unit) + .map_err(|_| Status::invalid_argument("Invalid currency unit"))?; + + let options = options.ok_or_else(|| Status::invalid_argument("Missing payment options"))?; + + let proto_options = match options + .options + .ok_or_else(|| Status::invalid_argument("Missing options"))? + { + incoming_payment_options::Options::Bolt11(opts) => { + IncomingPaymentOptions::Bolt11(cdk_common::payment::Bolt11IncomingPaymentOptions { + description: opts.description, + amount: opts.amount.into(), + unix_expiry: opts.unix_expiry, + }) + } + incoming_payment_options::Options::Bolt12(opts) => IncomingPaymentOptions::Bolt12( + Box::new(cdk_common::payment::Bolt12IncomingPaymentOptions { + description: opts.description, + amount: opts.amount.map(Into::into), + unix_expiry: opts.unix_expiry, + }), + ), + }; - let unit = - CurrencyUnit::from_str(&unit).map_err(|_| Status::invalid_argument("Invalid unit"))?; let invoice_response = self .inner - .create_incoming_payment_request(amount.into(), &unit, description, unix_expiry) + .create_incoming_payment_request(&unit, proto_options) .await .map_err(|_| Status::internal("Could not create invoice"))?; @@ -203,21 +224,46 @@ impl CdkPaymentProcessor for PaymentProcessorServer { ) -> Result, Status> { let request = request.into_inner(); - let options: Option = - request.options.as_ref().map(|options| (*options).into()); + let unit = CurrencyUnit::from_str(&request.unit) + .map_err(|_| Status::invalid_argument("Invalid currency unit"))?; + + let options = match request.request_type() { + OutgoingPaymentRequestType::Bolt11Invoice => { + let bolt11: cdk_common::Bolt11Invoice = + request.request.parse().map_err(Error::Invoice)?; + + cdk_common::payment::OutgoingPaymentOptions::Bolt11(Box::new( + cdk_common::payment::Bolt11OutgoingPaymentOptions { + bolt11, + max_fee_amount: None, + timeout_secs: None, + melt_options: request.options.map(Into::into), + }, + )) + } + OutgoingPaymentRequestType::Bolt12Offer => { + // Parse offer to verify it's valid, but store as string + let _: Offer = request.request.parse().map_err(|_| Error::Bolt12Parse)?; + + cdk_common::payment::OutgoingPaymentOptions::Bolt12(Box::new( + cdk_common::payment::Bolt12OutgoingPaymentOptions { + offer: Offer::from_str(&request.request).unwrap(), + max_fee_amount: None, + timeout_secs: None, + invoice: None, + melt_options: request.options.map(Into::into), + }, + )) + } + }; let payment_quote = self .inner - .get_payment_quote( - &request.request, - &CurrencyUnit::from_str(&request.unit) - .map_err(|_| Status::invalid_argument("Invalid currency unit"))?, - options, - ) + .get_payment_quote(&unit, options) .await .map_err(|err| { - tracing::error!("Could not get bolt11 melt quote: {}", err); - Status::internal("Could not get melt quote") + tracing::error!("Could not get payment quote: {}", err); + Status::internal("Could not get quote") })?; Ok(Response::new(payment_quote.into())) @@ -229,17 +275,51 @@ impl CdkPaymentProcessor for PaymentProcessorServer { ) -> Result, Status> { let request = request.into_inner(); - let pay_invoice = self + let options = request + .payment_options + .ok_or_else(|| Status::invalid_argument("Missing payment options"))?; + + let (unit, payment_options) = match options + .options + .ok_or_else(|| Status::invalid_argument("Missing options"))? + { + outgoing_payment_variant::Options::Bolt11(opts) => { + let bolt11: cdk_common::Bolt11Invoice = + opts.bolt11.parse().map_err(Error::Invoice)?; + + let payment_options = cdk_common::payment::OutgoingPaymentOptions::Bolt11( + Box::new(cdk_common::payment::Bolt11OutgoingPaymentOptions { + bolt11, + max_fee_amount: opts.max_fee_amount.map(Into::into), + timeout_secs: opts.timeout_secs, + melt_options: opts.melt_options.map(Into::into), + }), + ); + + (CurrencyUnit::Msat, payment_options) + } + outgoing_payment_variant::Options::Bolt12(opts) => { + let offer = Offer::from_str(&opts.offer) + .map_err(|_| Error::Bolt12Parse) + .unwrap(); + + let payment_options = cdk_common::payment::OutgoingPaymentOptions::Bolt12( + Box::new(cdk_common::payment::Bolt12OutgoingPaymentOptions { + offer, + max_fee_amount: opts.max_fee_amount.map(Into::into), + timeout_secs: opts.timeout_secs, + invoice: opts.invoice, + melt_options: opts.melt_options.map(Into::into), + }), + ); + + (CurrencyUnit::Msat, payment_options) + } + }; + + let pay_response = self .inner - .make_payment( - request - .melt_quote - .ok_or(Status::invalid_argument("Meltquote is required"))? - .try_into() - .map_err(|_err| Status::invalid_argument("Invalid melt quote"))?, - request.partial_amount.map(|a| a.into()), - request.max_fee_amount.map(|a| a.into()), - ) + .make_payment(&unit, payment_options) .await .map_err(|err| { tracing::error!("Could not make payment: {}", err); @@ -255,7 +335,7 @@ impl CdkPaymentProcessor for PaymentProcessorServer { } })?; - Ok(Response::new(pay_invoice.into())) + Ok(Response::new(pay_response.into())) } async fn check_incoming_payment( @@ -264,14 +344,20 @@ impl CdkPaymentProcessor for PaymentProcessorServer { ) -> Result, Status> { let request = request.into_inner(); - let check_response = self + let payment_identifier = request + .request_identifier + .ok_or_else(|| Status::invalid_argument("Missing request identifier"))? + .try_into() + .map_err(|_| Status::invalid_argument("Invalid request identifier"))?; + + let check_responses = self .inner - .check_incoming_payment_status(&request.request_lookup_id) + .check_incoming_payment_status(&payment_identifier) .await .map_err(|_| Status::internal("Could not check incoming payment status"))?; Ok(Response::new(CheckIncomingPaymentResponse { - status: QuoteState::from(check_response).into(), + payments: check_responses.into_iter().map(|r| r.into()).collect(), })) } @@ -281,23 +367,28 @@ impl CdkPaymentProcessor for PaymentProcessorServer { ) -> Result, Status> { let request = request.into_inner(); + let payment_identifier = request + .request_identifier + .ok_or_else(|| Status::invalid_argument("Missing request identifier"))? + .try_into() + .map_err(|_| Status::invalid_argument("Invalid request identifier"))?; + let check_response = self .inner - .check_outgoing_payment(&request.request_lookup_id) + .check_outgoing_payment(&payment_identifier) .await - .map_err(|_| Status::internal("Could not check incoming payment status"))?; + .map_err(|_| Status::internal("Could not check outgoing payment status"))?; Ok(Response::new(check_response.into())) } type WaitIncomingPaymentStream = ResponseStream; - // Clippy thinks select is not stable but it compiles fine on MSRV (1.63.0) #[allow(clippy::incompatible_msrv)] #[instrument(skip_all)] async fn wait_incoming_payment( &self, - _request: Request, + _request: Request, ) -> Result, Status> { tracing::debug!("Server waiting for payment stream"); let (tx, rx) = mpsc::channel(128); @@ -307,34 +398,35 @@ impl CdkPaymentProcessor for PaymentProcessorServer { tokio::spawn(async move { loop { tokio::select! { - _ = shutdown_clone.notified() => { - tracing::info!("Shutdown signal received, stopping task for "); - ln.cancel_wait_invoice(); - break; - } - result = ln.wait_any_incoming_payment() => { - match result { - Ok(mut stream) => { - while let Some(request_lookup_id) = stream.next().await { - match tx.send(Result::<_, Status>::Ok(WaitIncomingPaymentResponse{lookup_id: request_lookup_id} )).await { - Ok(_) => { - // item (server response) was queued to be send to client - } - Err(item) => { - tracing::error!("Error adding incoming payment to stream: {}", item); + _ = shutdown_clone.notified() => { + tracing::info!("Shutdown signal received, stopping task"); + ln.cancel_wait_invoice(); break; } - } + result = ln.wait_any_incoming_payment() => { + match result { + Ok(mut stream) => { + while let Some(payment_response) = stream.next().await { + match tx.send(Result::<_, Status>::Ok(payment_response.into())) + .await + { + Ok(_) => { + // Response was queued to be sent to client + } + Err(item) => { + tracing::error!("Error adding incoming payment to stream: {}", item); + break; + } + } + } + } + Err(err) => { + tracing::warn!("Could not get invoice stream: {}", err); + tokio::time::sleep(std::time::Duration::from_secs(5)).await; } } - Err(err) => { - tracing::warn!("Could not get invoice stream for {}", err); - - tokio::time::sleep(std::time::Duration::from_secs(5)).await; - } } } - } } }); diff --git a/crates/cdk-sqlite/src/mint/error.rs b/crates/cdk-sqlite/src/mint/error.rs index ad521063..cbc10448 100644 --- a/crates/cdk-sqlite/src/mint/error.rs +++ b/crates/cdk-sqlite/src/mint/error.rs @@ -101,6 +101,9 @@ pub enum Error { /// Invalid keyset ID #[error("Invalid keyset ID")] InvalidKeysetId, + /// Invalid melt payment request + #[error("Invalid melt payment request")] + InvalidMeltPaymentRequest, } impl From for cdk_common::database::Error { diff --git a/crates/cdk-sqlite/src/mint/memory.rs b/crates/cdk-sqlite/src/mint/memory.rs index 735f7670..54864443 100644 --- a/crates/cdk-sqlite/src/mint/memory.rs +++ b/crates/cdk-sqlite/src/mint/memory.rs @@ -44,7 +44,7 @@ pub async fn new_with_state( let mut tx = MintDatabase::begin_transaction(&db).await?; for quote in mint_quotes { - tx.add_or_replace_mint_quote(quote).await?; + tx.add_mint_quote(quote).await?; } for quote in melt_quotes { diff --git a/crates/cdk-sqlite/src/mint/migrations.rs b/crates/cdk-sqlite/src/mint/migrations.rs index 9384c496..d8e93c90 100644 --- a/crates/cdk-sqlite/src/mint/migrations.rs +++ b/crates/cdk-sqlite/src/mint/migrations.rs @@ -21,4 +21,5 @@ pub static MIGRATIONS: &[(&str, &str)] = &[ ("20250406093755_mint_created_time_signature.sql", include_str!(r#"./migrations/20250406093755_mint_created_time_signature.sql"#)), ("20250415093121_drop_keystore_foreign.sql", include_str!(r#"./migrations/20250415093121_drop_keystore_foreign.sql"#)), ("20250626120251_rename_blind_message_y_to_b.sql", include_str!(r#"./migrations/20250626120251_rename_blind_message_y_to_b.sql"#)), + ("20250706101057_bolt12.sql", include_str!(r#"./migrations/20250706101057_bolt12.sql"#)), ]; diff --git a/crates/cdk-sqlite/src/mint/migrations/20250706101057_bolt12.sql b/crates/cdk-sqlite/src/mint/migrations/20250706101057_bolt12.sql new file mode 100644 index 00000000..94d690a6 --- /dev/null +++ b/crates/cdk-sqlite/src/mint/migrations/20250706101057_bolt12.sql @@ -0,0 +1,81 @@ +-- Add new columns to mint_quote table +ALTER TABLE mint_quote ADD COLUMN amount_paid INTEGER NOT NULL DEFAULT 0; +ALTER TABLE mint_quote ADD COLUMN amount_issued INTEGER NOT NULL DEFAULT 0; +ALTER TABLE mint_quote ADD COLUMN payment_method TEXT NOT NULL DEFAULT 'BOLT11'; +ALTER TABLE mint_quote DROP COLUMN issued_time; +ALTER TABLE mint_quote DROP COLUMN paid_time; + +-- Set amount_paid equal to amount for quotes with PAID or ISSUED state +UPDATE mint_quote SET amount_paid = amount WHERE state = 'PAID' OR state = 'ISSUED'; + +-- Set amount_issued equal to amount for quotes with ISSUED state +UPDATE mint_quote SET amount_issued = amount WHERE state = 'ISSUED'; + +DROP INDEX IF EXISTS mint_quote_state_index; + +-- Remove the state column from mint_quote table +ALTER TABLE mint_quote DROP COLUMN state; + +-- Remove NOT NULL constraint from amount column +CREATE TABLE mint_quote_temp ( + id TEXT PRIMARY KEY, + amount INTEGER, + unit TEXT NOT NULL, + request TEXT NOT NULL, + expiry INTEGER NOT NULL, + request_lookup_id TEXT UNIQUE, + pubkey TEXT, + created_time INTEGER NOT NULL DEFAULT 0, + amount_paid INTEGER NOT NULL DEFAULT 0, + amount_issued INTEGER NOT NULL DEFAULT 0, + payment_method TEXT NOT NULL DEFAULT 'BOLT11' +); + +INSERT INTO mint_quote_temp (id, amount, unit, request, expiry, request_lookup_id, pubkey, created_time, amount_paid, amount_issued, payment_method) +SELECT id, amount, unit, request, expiry, request_lookup_id, pubkey, created_time, amount_paid, amount_issued, payment_method +FROM mint_quote; + +DROP TABLE mint_quote; +ALTER TABLE mint_quote_temp RENAME TO mint_quote; + +ALTER TABLE mint_quote ADD COLUMN request_lookup_id_kind TEXT NOT NULL DEFAULT 'payment_hash'; + +CREATE INDEX IF NOT EXISTS idx_mint_quote_created_time ON mint_quote(created_time); +CREATE INDEX IF NOT EXISTS idx_mint_quote_expiry ON mint_quote(expiry); +CREATE INDEX IF NOT EXISTS idx_mint_quote_request_lookup_id ON mint_quote(request_lookup_id); +CREATE INDEX IF NOT EXISTS idx_mint_quote_request_lookup_id_and_kind ON mint_quote(request_lookup_id, request_lookup_id_kind); + +-- Create mint_quote_payments table +CREATE TABLE mint_quote_payments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + quote_id TEXT NOT NULL, + payment_id TEXT NOT NULL UNIQUE, + timestamp INTEGER NOT NULL, + amount INTEGER NOT NULL, + FOREIGN KEY (quote_id) REFERENCES mint_quote(id) +); + +-- Create index on payment_id for faster lookups +CREATE INDEX idx_mint_quote_payments_payment_id ON mint_quote_payments(payment_id); +CREATE INDEX idx_mint_quote_payments_quote_id ON mint_quote_payments(quote_id); + +-- Create mint_quote_issued table +CREATE TABLE mint_quote_issued ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + quote_id TEXT NOT NULL, + amount INTEGER NOT NULL, + timestamp INTEGER NOT NULL, + FOREIGN KEY (quote_id) REFERENCES mint_quote(id) +); + +-- Create index on quote_id for faster lookups +CREATE INDEX idx_mint_quote_issued_quote_id ON mint_quote_issued(quote_id); + +-- Add new columns to melt_quote table +ALTER TABLE melt_quote ADD COLUMN payment_method TEXT NOT NULL DEFAULT 'bolt11'; +ALTER TABLE melt_quote ADD COLUMN options TEXT; +ALTER TABLE melt_quote ADD COLUMN request_lookup_id_kind TEXT NOT NULL DEFAULT 'payment_hash'; + +CREATE INDEX IF NOT EXISTS idx_melt_quote_request_lookup_id_and_kind ON mint_quote(request_lookup_id, request_lookup_id_kind); + +ALTER TABLE melt_quote DROP COLUMN msat_to_pay; diff --git a/crates/cdk-sqlite/src/mint/mod.rs b/crates/cdk-sqlite/src/mint/mod.rs index 6f6cd03c..c2cf5796 100644 --- a/crates/cdk-sqlite/src/mint/mod.rs +++ b/crates/cdk-sqlite/src/mint/mod.rs @@ -14,18 +14,21 @@ use cdk_common::database::{ MintProofsDatabase, MintProofsTransaction, MintQuotesDatabase, MintQuotesTransaction, MintSignatureTransaction, MintSignaturesDatabase, }; -use cdk_common::mint::{self, MintKeySetInfo, MintQuote}; +use cdk_common::mint::{ + self, IncomingPayment, Issuance, MeltPaymentRequest, MeltQuote, MintKeySetInfo, MintQuote, +}; use cdk_common::nut00::ProofsMethods; -use cdk_common::nut05::QuoteState; +use cdk_common::payment::PaymentIdentifier; use cdk_common::secret::Secret; use cdk_common::state::check_state_transition; use cdk_common::util::unix_time; use cdk_common::{ Amount, BlindSignature, BlindSignatureDleq, CurrencyUnit, Id, MeltQuoteState, MintInfo, - MintQuoteState, Proof, Proofs, PublicKey, SecretKey, State, + PaymentMethod, Proof, Proofs, PublicKey, SecretKey, State, }; use error::Error; use lightning_invoice::Bolt11Invoice; +use tracing::instrument; use uuid::Uuid; use crate::common::{create_sqlite_pool, migrate}; @@ -95,6 +98,66 @@ where Ok(()) } +#[inline(always)] +async fn get_mint_quote_payments( + conn: &C, + quote_id: &Uuid, +) -> Result, Error> +where + C: DatabaseExecutor + Send + Sync, +{ + // Get payment IDs and timestamps from the mint_quote_payments table + query( + r#" +SELECT payment_id, timestamp, amount +FROM mint_quote_payments +WHERE quote_id=:quote_id; + "#, + ) + .bind(":quote_id", quote_id.as_hyphenated().to_string()) + .fetch_all(conn) + .await? + .into_iter() + .map(|row| { + let amount: u64 = column_as_number!(row[2].clone()); + let time: u64 = column_as_number!(row[1].clone()); + Ok(IncomingPayment::new( + amount.into(), + column_as_string!(&row[0]), + time, + )) + }) + .collect() +} + +#[inline(always)] +async fn get_mint_quote_issuance(conn: &C, quote_id: &Uuid) -> Result, Error> +where + C: DatabaseExecutor + Send + Sync, +{ + // Get payment IDs and timestamps from the mint_quote_payments table + query( + r#" +SELECT amount, timestamp +FROM mint_quote_issued +WHERE quote_id=:quote_id; + "#, + ) + .bind(":quote_id", quote_id.as_hyphenated().to_string()) + .fetch_all(conn) + .await? + .into_iter() + .map(|row| { + let time: u64 = column_as_number!(row[1].clone()); + Ok(Issuance::new( + Amount::from_i64(column_as_number!(row[0].clone())) + .expect("Is amount when put into db"), + time, + )) + }) + .collect() +} + impl MintSqliteDatabase { /// Create new [`MintSqliteDatabase`] #[cfg(not(feature = "sqlcipher"))] @@ -324,30 +387,193 @@ impl MintKeysDatabase for MintSqliteDatabase { impl<'a> MintQuotesTransaction<'a> for SqliteTransaction<'a> { type Err = database::Error; - async fn add_or_replace_mint_quote(&mut self, quote: MintQuote) -> Result<(), Self::Err> { + #[instrument(skip(self))] + async fn increment_mint_quote_amount_paid( + &mut self, + quote_id: &Uuid, + amount_paid: Amount, + payment_id: String, + ) -> Result { + // Check if payment_id already exists in mint_quote_payments + let exists = query( + r#" + SELECT payment_id + FROM mint_quote_payments + WHERE payment_id = :payment_id + "#, + ) + .bind(":payment_id", payment_id.clone()) + .fetch_one(&self.inner) + .await?; + + if exists.is_some() { + tracing::error!("Payment ID already exists: {}", payment_id); + return Err(database::Error::Duplicate); + } + + // Get current amount_paid from quote + let current_amount = query( + r#" + SELECT amount_paid + FROM mint_quote + WHERE id = :quote_id + "#, + ) + .bind(":quote_id", quote_id.as_hyphenated().to_string()) + .fetch_one(&self.inner) + .await + .map_err(|err| { + tracing::error!("SQLite could not get mint quote amount_paid"); + err + })?; + + let current_amount_paid = if let Some(current_amount) = current_amount { + let amount: u64 = column_as_number!(current_amount[0].clone()); + Amount::from(amount) + } else { + Amount::ZERO + }; + + // Calculate new amount_paid with overflow check + let new_amount_paid = current_amount_paid + .checked_add(amount_paid) + .ok_or_else(|| database::Error::AmountOverflow)?; + + // Update the amount_paid query( r#" - INSERT OR REPLACE INTO mint_quote ( - id, amount, unit, request, state, expiry, request_lookup_id, - pubkey, created_time, paid_time, issued_time + UPDATE mint_quote + SET amount_paid = :amount_paid + WHERE id = :quote_id + "#, + ) + .bind(":amount_paid", new_amount_paid.to_i64()) + .bind(":quote_id", quote_id.as_hyphenated().to_string()) + .execute(&self.inner) + .await + .map_err(|err| { + tracing::error!("SQLite could not update mint quote amount_paid"); + err + })?; + + // Add payment_id to mint_quote_payments table + query( + r#" + INSERT INTO mint_quote_payments + (quote_id, payment_id, amount, timestamp) + VALUES (:quote_id, :payment_id, :amount, :timestamp) + "#, + ) + .bind(":quote_id", quote_id.as_hyphenated().to_string()) + .bind(":payment_id", payment_id) + .bind(":amount", amount_paid.to_i64()) + .bind(":timestamp", unix_time() as i64) + .execute(&self.inner) + .await + .map_err(|err| { + tracing::error!("SQLite could not insert payment ID: {}", err); + err + })?; + + Ok(new_amount_paid) + } + + #[instrument(skip_all)] + async fn increment_mint_quote_amount_issued( + &mut self, + quote_id: &Uuid, + amount_issued: Amount, + ) -> Result { + // Get current amount_issued from quote + let current_amount = query( + r#" + SELECT amount_issued + FROM mint_quote + WHERE id = :quote_id + "#, + ) + .bind(":quote_id", quote_id.as_hyphenated().to_string()) + .fetch_one(&self.inner) + .await + .map_err(|err| { + tracing::error!("SQLite could not get mint quote amount_issued"); + err + })?; + + let current_amount_issued = if let Some(current_amount) = current_amount { + let amount: u64 = column_as_number!(current_amount[0].clone()); + Amount::from(amount) + } else { + Amount::ZERO + }; + + // Calculate new amount_issued with overflow check + let new_amount_issued = current_amount_issued + .checked_add(amount_issued) + .ok_or_else(|| database::Error::AmountOverflow)?; + + // Update the amount_issued + query( + r#" + UPDATE mint_quote + SET amount_issued = :amount_issued + WHERE id = :quote_id + "#, + ) + .bind(":amount_issued", new_amount_issued.to_i64()) + .bind(":quote_id", quote_id.as_hyphenated().to_string()) + .execute(&self.inner) + .await + .map_err(|err| { + tracing::error!("SQLite could not update mint quote amount_issued"); + err + })?; + + let current_time = unix_time(); + + query( + r#" +INSERT INTO mint_quote_issued +(quote_id, amount, timestamp) +VALUES (:quote_id, :amount, :timestamp); + "#, + ) + .bind(":quote_id", quote_id.as_hyphenated().to_string()) + .bind(":amount", amount_issued.to_i64()) + .bind(":timestamp", current_time as i64) + .execute(&self.inner) + .await?; + + Ok(new_amount_issued) + } + + #[instrument(skip_all)] + async fn add_mint_quote(&mut self, quote: MintQuote) -> Result<(), Self::Err> { + tracing::debug!("Adding quote with: {}", quote.payment_method.to_string()); + println!("Adding quote with: {}", quote.payment_method.to_string()); + query( + r#" + INSERT INTO mint_quote ( + id, amount, unit, request, expiry, request_lookup_id, pubkey, created_time, payment_method, request_lookup_id_kind ) VALUES ( - :id, :amount, :unit, :request, :state, :expiry, :request_lookup_id, - :pubkey, :created_time, :paid_time, :issued_time + :id, :amount, :unit, :request, :expiry, :request_lookup_id, :pubkey, :created_time, :payment_method, :request_lookup_id_kind ) "#, ) .bind(":id", quote.id.to_string()) - .bind(":amount", u64::from(quote.amount) as i64) + .bind(":amount", quote.amount.map(|a| a.to_i64())) .bind(":unit", quote.unit.to_string()) .bind(":request", quote.request) - .bind(":state", quote.state.to_string()) .bind(":expiry", quote.expiry as i64) - .bind(":request_lookup_id", quote.request_lookup_id) + .bind( + ":request_lookup_id", + quote.request_lookup_id.to_string(), + ) .bind(":pubkey", quote.pubkey.map(|p| p.to_string())) .bind(":created_time", quote.created_time as i64) - .bind(":paid_time", quote.paid_time.map(|t| t as i64)) - .bind(":issued_time", quote.issued_time.map(|t| t as i64)) + .bind(":payment_method", quote.payment_method.to_string()) + .bind(":request_lookup_id_kind", quote.request_lookup_id.kind()) .execute(&self.inner) .await?; @@ -389,32 +615,33 @@ impl<'a> MintQuotesTransaction<'a> for SqliteTransaction<'a> { INSERT INTO melt_quote ( id, unit, amount, request, fee_reserve, state, - expiry, payment_preimage, request_lookup_id, msat_to_pay, - created_time, paid_time + expiry, payment_preimage, request_lookup_id, + created_time, paid_time, options, request_lookup_id_kind ) VALUES ( :id, :unit, :amount, :request, :fee_reserve, :state, - :expiry, :payment_preimage, :request_lookup_id, :msat_to_pay, - :created_time, :paid_time + :expiry, :payment_preimage, :request_lookup_id, + :created_time, :paid_time, :options, :request_lookup_id_kind ) "#, ) .bind(":id", quote.id.to_string()) .bind(":unit", quote.unit.to_string()) - .bind(":amount", u64::from(quote.amount) as i64) - .bind(":request", quote.request) - .bind(":fee_reserve", u64::from(quote.fee_reserve) as i64) + .bind(":amount", quote.amount.to_i64()) + .bind(":request", serde_json::to_string("e.request)?) + .bind(":fee_reserve", quote.fee_reserve.to_i64()) .bind(":state", quote.state.to_string()) .bind(":expiry", quote.expiry as i64) .bind(":payment_preimage", quote.payment_preimage) - .bind(":request_lookup_id", quote.request_lookup_id) - .bind( - ":msat_to_pay", - quote.msat_to_pay.map(|a| u64::from(a) as i64), - ) + .bind(":request_lookup_id", quote.request_lookup_id.to_string()) .bind(":created_time", quote.created_time as i64) .bind(":paid_time", quote.paid_time.map(|t| t as i64)) + .bind( + ":options", + quote.options.map(|o| serde_json::to_string(&o).ok()), + ) + .bind(":request_lookup_id_kind", quote.request_lookup_id.kind()) .execute(&self.inner) .await?; @@ -424,10 +651,11 @@ impl<'a> MintQuotesTransaction<'a> for SqliteTransaction<'a> { async fn update_melt_quote_request_lookup_id( &mut self, quote_id: &Uuid, - new_request_lookup_id: &str, + new_request_lookup_id: &PaymentIdentifier, ) -> Result<(), Self::Err> { - query(r#"UPDATE melt_quote SET request_lookup_id = :new_req_id WHERE id = :id"#) - .bind(":new_req_id", new_request_lookup_id.to_owned()) + query(r#"UPDATE melt_quote SET request_lookup_id = :new_req_id, request_lookup_id_kind = :new_kind WHERE id = :id"#) + .bind(":new_req_id", new_request_lookup_id.to_string()) + .bind(":new_kind",new_request_lookup_id.kind() ) .bind(":id", quote_id.as_hyphenated().to_string()) .execute(&self.inner) .await?; @@ -438,6 +666,7 @@ impl<'a> MintQuotesTransaction<'a> for SqliteTransaction<'a> { &mut self, quote_id: &Uuid, state: MeltQuoteState, + payment_proof: Option, ) -> Result<(MeltQuoteState, mint::MeltQuote), Self::Err> { let mut quote = query( r#" @@ -447,13 +676,15 @@ impl<'a> MintQuotesTransaction<'a> for SqliteTransaction<'a> { amount, request, fee_reserve, - state, expiry, + state, payment_preimage, request_lookup_id, - msat_to_pay, created_time, - paid_time + paid_time, + payment_method, + options, + request_lookup_id_kind FROM melt_quote WHERE @@ -471,9 +702,10 @@ impl<'a> MintQuotesTransaction<'a> for SqliteTransaction<'a> { let rec = if state == MeltQuoteState::Paid { let current_time = unix_time(); - query(r#"UPDATE melt_quote SET state = :state, paid_time = :paid_time WHERE id = :id"#) + query(r#"UPDATE melt_quote SET state = :state, paid_time = :paid_time, payment_preimage = :payment_preimage WHERE id = :id"#) .bind(":state", state.to_string()) .bind(":paid_time", current_time as i64) + .bind(":payment_preimage", payment_proof) .bind(":id", quote_id.as_hyphenated().to_string()) .execute(&self.inner) .await @@ -513,72 +745,10 @@ impl<'a> MintQuotesTransaction<'a> for SqliteTransaction<'a> { Ok(()) } - async fn update_mint_quote_state( - &mut self, - quote_id: &Uuid, - state: MintQuoteState, - ) -> Result { - let quote = query( - r#" - SELECT - id, - amount, - unit, - request, - state, - expiry, - request_lookup_id, - pubkey, - created_time, - paid_time, - issued_time - FROM - mint_quote - WHERE id = :id"#, - ) - .bind(":id", quote_id.as_hyphenated().to_string()) - .fetch_one(&self.inner) - .await? - .map(sqlite_row_to_mint_quote) - .ok_or(Error::QuoteNotFound)??; - - let update_query = match state { - MintQuoteState::Paid => { - r#"UPDATE mint_quote SET state = :state, paid_time = :current_time WHERE id = :quote_id"# - } - MintQuoteState::Issued => { - r#"UPDATE mint_quote SET state = :state, issued_time = :current_time WHERE id = :quote_id"# - } - _ => r#"UPDATE mint_quote SET state = :state WHERE id = :quote_id"#, - }; - - let current_time = unix_time(); - - let update = match state { - MintQuoteState::Paid => query(update_query) - .bind(":state", state.to_string()) - .bind(":current_time", current_time as i64) - .bind(":quote_id", quote_id.as_hyphenated().to_string()), - MintQuoteState::Issued => query(update_query) - .bind(":state", state.to_string()) - .bind(":current_time", current_time as i64) - .bind(":quote_id", quote_id.as_hyphenated().to_string()), - _ => query(update_query) - .bind(":state", state.to_string()) - .bind(":quote_id", quote_id.as_hyphenated().to_string()), - }; - - match update.execute(&self.inner).await { - Ok(_) => Ok(quote.state), - Err(err) => { - tracing::error!("SQLite Could not update keyset: {:?}", err); - - return Err(err.into()); - } - } - } - async fn get_mint_quote(&mut self, quote_id: &Uuid) -> Result, Self::Err> { + let payments = get_mint_quote_payments(&self.inner, quote_id).await?; + let issuance = get_mint_quote_issuance(&self.inner, quote_id).await?; + Ok(query( r#" SELECT @@ -586,13 +756,14 @@ impl<'a> MintQuotesTransaction<'a> for SqliteTransaction<'a> { amount, unit, request, - state, expiry, request_lookup_id, pubkey, created_time, - paid_time, - issued_time + amount_paid, + amount_issued, + payment_method, + request_lookup_id_kind FROM mint_quote WHERE id = :id"#, @@ -600,7 +771,7 @@ impl<'a> MintQuotesTransaction<'a> for SqliteTransaction<'a> { .bind(":id", quote_id.as_hyphenated().to_string()) .fetch_one(&self.inner) .await? - .map(sqlite_row_to_mint_quote) + .map(|row| sqlite_row_to_mint_quote(row, payments, issuance)) .transpose()?) } @@ -610,19 +781,21 @@ impl<'a> MintQuotesTransaction<'a> for SqliteTransaction<'a> { ) -> Result, Self::Err> { Ok(query( r#" - SELECT + SELECT id, unit, amount, request, fee_reserve, - state, expiry, + state, payment_preimage, request_lookup_id, - msat_to_pay, created_time, - paid_time + paid_time, + payment_method, + options, + request_lookup_id FROM melt_quote WHERE @@ -640,29 +813,81 @@ impl<'a> MintQuotesTransaction<'a> for SqliteTransaction<'a> { &mut self, request: &str, ) -> Result, Self::Err> { - Ok(query( + let mut mint_quote = query( r#" SELECT id, amount, unit, request, - state, expiry, request_lookup_id, pubkey, created_time, - paid_time, - issued_time + amount_paid, + amount_issued, + payment_method, + request_lookup_id_kind FROM mint_quote WHERE request = :request"#, ) - .bind(":request", request.to_owned()) + .bind(":request", request.to_string()) .fetch_one(&self.inner) .await? - .map(sqlite_row_to_mint_quote) - .transpose()?) + .map(|row| sqlite_row_to_mint_quote(row, vec![], vec![])) + .transpose()?; + + if let Some(quote) = mint_quote.as_mut() { + let payments = get_mint_quote_payments(&self.inner, "e.id).await?; + let issuance = get_mint_quote_issuance(&self.inner, "e.id).await?; + quote.issuance = issuance; + quote.payments = payments; + } + + Ok(mint_quote) + } + + async fn get_mint_quote_by_request_lookup_id( + &mut self, + request_lookup_id: &PaymentIdentifier, + ) -> Result, Self::Err> { + let mut mint_quote = query( + r#" + SELECT + id, + amount, + unit, + request, + expiry, + request_lookup_id, + pubkey, + created_time, + amount_paid, + amount_issued, + payment_method, + request_lookup_id_kind + FROM + mint_quote + WHERE request_lookup_id = :request_lookup_id + AND request_lookup_id_kind = :request_lookup_id_kind + "#, + ) + .bind(":request_lookup_id", request_lookup_id.to_string()) + .bind(":request_lookup_id_kind", request_lookup_id.kind()) + .fetch_one(&self.inner) + .await? + .map(|row| sqlite_row_to_mint_quote(row, vec![], vec![])) + .transpose()?; + + if let Some(quote) = mint_quote.as_mut() { + let payments = get_mint_quote_payments(&self.inner, "e.id).await?; + let issuance = get_mint_quote_issuance(&self.inner, "e.id).await?; + quote.issuance = issuance; + quote.payments = payments; + } + + Ok(mint_quote) } } @@ -671,6 +896,9 @@ impl MintQuotesDatabase for MintSqliteDatabase { type Err = database::Error; async fn get_mint_quote(&self, quote_id: &Uuid) -> Result, Self::Err> { + let payments = get_mint_quote_payments(&self.pool, quote_id).await?; + let issuance = get_mint_quote_issuance(&self.pool, quote_id).await?; + Ok(query( r#" SELECT @@ -678,13 +906,14 @@ impl MintQuotesDatabase for MintSqliteDatabase { amount, unit, request, - state, expiry, request_lookup_id, pubkey, created_time, - paid_time, - issued_time + amount_paid, + amount_issued, + payment_method, + request_lookup_id_kind FROM mint_quote WHERE id = :id"#, @@ -692,7 +921,7 @@ impl MintQuotesDatabase for MintSqliteDatabase { .bind(":id", quote_id.as_hyphenated().to_string()) .fetch_one(&self.pool) .await? - .map(sqlite_row_to_mint_quote) + .map(|row| sqlite_row_to_mint_quote(row, payments, issuance)) .transpose()?) } @@ -700,20 +929,21 @@ impl MintQuotesDatabase for MintSqliteDatabase { &self, request: &str, ) -> Result, Self::Err> { - Ok(query( + let mut mint_quote = query( r#" SELECT id, amount, unit, request, - state, expiry, request_lookup_id, pubkey, created_time, - paid_time, - issued_time + amount_paid, + amount_issued, + payment_method, + request_lookup_id_kind FROM mint_quote WHERE request = :request"#, @@ -721,95 +951,96 @@ impl MintQuotesDatabase for MintSqliteDatabase { .bind(":request", request.to_owned()) .fetch_one(&self.pool) .await? - .map(sqlite_row_to_mint_quote) - .transpose()?) + .map(|row| sqlite_row_to_mint_quote(row, vec![], vec![])) + .transpose()?; + + if let Some(quote) = mint_quote.as_mut() { + let payments = get_mint_quote_payments(&self.pool, "e.id).await?; + let issuance = get_mint_quote_issuance(&self.pool, "e.id).await?; + quote.issuance = issuance; + quote.payments = payments; + } + + Ok(mint_quote) } async fn get_mint_quote_by_request_lookup_id( &self, - request_lookup_id: &str, + request_lookup_id: &PaymentIdentifier, ) -> Result, Self::Err> { - Ok(query( + let mut mint_quote = query( r#" SELECT id, amount, unit, request, - state, expiry, request_lookup_id, pubkey, created_time, - paid_time, - issued_time + amount_paid, + amount_issued, + payment_method, + request_lookup_id_kind FROM mint_quote WHERE request_lookup_id = :request_lookup_id"#, ) - .bind(":request_lookup_id", request_lookup_id.to_owned()) + .bind( + ":request_lookup_id", + serde_json::to_string(request_lookup_id)?, + ) .fetch_one(&self.pool) .await? - .map(sqlite_row_to_mint_quote) - .transpose()?) + .map(|row| sqlite_row_to_mint_quote(row, vec![], vec![])) + .transpose()?; + + // TODO: these should use an sql join so they can be done in one query + if let Some(quote) = mint_quote.as_mut() { + let payments = get_mint_quote_payments(&self.pool, "e.id).await?; + let issuance = get_mint_quote_issuance(&self.pool, "e.id).await?; + quote.issuance = issuance; + quote.payments = payments; + } + + Ok(mint_quote) } async fn get_mint_quotes(&self) -> Result, Self::Err> { - Ok(query( + let mut mint_quotes = query( r#" - SELECT - id, - amount, - unit, - request, - state, - expiry, - request_lookup_id, - pubkey, - created_time, - paid_time, - issued_time - FROM - mint_quote - "#, + SELECT + id, + amount, + unit, + request, + expiry, + request_lookup_id, + pubkey, + created_time, + amount_paid, + amount_issued, + payment_method, + request_lookup_id_kind + FROM + mint_quote + "#, ) .fetch_all(&self.pool) .await? .into_iter() - .map(sqlite_row_to_mint_quote) - .collect::, _>>()?) - } + .map(|row| sqlite_row_to_mint_quote(row, vec![], vec![])) + .collect::, _>>()?; - async fn get_mint_quotes_with_state( - &self, - state: MintQuoteState, - ) -> Result, Self::Err> { - Ok(query( - r#" - SELECT - id, - amount, - unit, - request, - state, - expiry, - request_lookup_id, - pubkey, - created_time, - paid_time, - issued_time - FROM - mint_quote - WHERE - state = :state - "#, - ) - .bind(":state", state.to_string()) - .fetch_all(&self.pool) - .await? - .into_iter() - .map(sqlite_row_to_mint_quote) - .collect::, _>>()?) + for quote in mint_quotes.as_mut_slice() { + let payments = get_mint_quote_payments(&self.pool, "e.id).await?; + let issuance = get_mint_quote_issuance(&self.pool, "e.id).await?; + quote.issuance = issuance; + quote.payments = payments; + } + + Ok(mint_quotes) } async fn get_melt_quote(&self, quote_id: &Uuid) -> Result, Self::Err> { @@ -821,13 +1052,15 @@ impl MintQuotesDatabase for MintSqliteDatabase { amount, request, fee_reserve, - state, expiry, + state, payment_preimage, request_lookup_id, - msat_to_pay, created_time, - paid_time + paid_time, + payment_method, + options, + request_lookup_id_kind FROM melt_quote WHERE @@ -844,19 +1077,21 @@ impl MintQuotesDatabase for MintSqliteDatabase { async fn get_melt_quotes(&self) -> Result, Self::Err> { Ok(query( r#" - SELECT + SELECT id, unit, amount, request, fee_reserve, - state, expiry, + state, payment_preimage, request_lookup_id, - msat_to_pay, created_time, - paid_time + paid_time, + payment_method, + options, + request_lookup_id_kind FROM melt_quote "#, @@ -910,7 +1145,7 @@ impl<'a> MintProofsTransaction<'a> for SqliteTransaction<'a> { "#, ) .bind(":y", proof.y()?.to_bytes().to_vec()) - .bind(":amount", u64::from(proof.amount) as i64) + .bind(":amount", proof.amount.to_i64()) .bind(":keyset_id", proof.keyset_id.to_string()) .bind(":secret", proof.secret.to_string()) .bind(":c", proof.c.to_bytes().to_vec()) @@ -984,6 +1219,7 @@ impl<'a> MintProofsTransaction<'a> for SqliteTransaction<'a> { impl MintProofsDatabase for MintSqliteDatabase { type Err = database::Error; + #[instrument(skip_all)] async fn get_proofs_by_ys(&self, ys: &[PublicKey]) -> Result>, Self::Err> { let mut proofs = query( r#" @@ -1019,6 +1255,7 @@ impl MintProofsDatabase for MintSqliteDatabase { Ok(ys.iter().map(|y| proofs.remove(y)).collect()) } + #[instrument(skip(self))] async fn get_proof_ys_by_quote_id(&self, quote_id: &Uuid) -> Result, Self::Err> { Ok(query( r#" @@ -1043,12 +1280,14 @@ impl MintProofsDatabase for MintSqliteDatabase { .ys()?) } + #[instrument(skip_all)] async fn get_proofs_states(&self, ys: &[PublicKey]) -> Result>, Self::Err> { let mut current_states = get_current_states(&self.pool, ys).await?; Ok(ys.iter().map(|y| current_states.remove(y)).collect()) } + #[instrument(skip_all)] async fn get_proofs_by_keyset_id( &self, keyset_id: &Id, @@ -1083,6 +1322,7 @@ impl MintProofsDatabase for MintSqliteDatabase { impl<'a> MintSignatureTransaction<'a> for SqliteTransaction<'a> { type Err = database::Error; + #[instrument(skip_all)] async fn add_blind_signatures( &mut self, blinded_messages: &[PublicKey], @@ -1101,9 +1341,9 @@ impl<'a> MintSignatureTransaction<'a> for SqliteTransaction<'a> { "#, ) .bind(":blinded_message", message.to_bytes().to_vec()) - .bind(":amount", u64::from(signature.amount) as i64) + .bind(":amount", signature.amount.to_i64()) .bind(":keyset_id", signature.keyset_id.to_string()) - .bind(":c", signature.c.to_bytes().to_vec()) + .bind(":c", signature.c.to_hex()) .bind(":quote_id", quote_id.map(|q| q.hyphenated().to_string())) .bind( ":dleq_e", @@ -1121,6 +1361,7 @@ impl<'a> MintSignatureTransaction<'a> for SqliteTransaction<'a> { Ok(()) } + #[instrument(skip_all)] async fn get_blind_signatures( &mut self, blinded_messages: &[PublicKey], @@ -1135,11 +1376,11 @@ impl<'a> MintSignatureTransaction<'a> for SqliteTransaction<'a> { blinded_message FROM blind_signature - WHERE blinded_message IN (:y) + WHERE blinded_message IN (:blinded_message) "#, ) .bind_vec( - ":y", + ":blinded_message", blinded_messages .iter() .map(|y| y.to_bytes().to_vec()) @@ -1159,6 +1400,7 @@ impl<'a> MintSignatureTransaction<'a> for SqliteTransaction<'a> { )) }) .collect::, Error>>()?; + Ok(blinded_messages .iter() .map(|y| blinded_signatures.remove(y)) @@ -1318,58 +1560,72 @@ fn sqlite_row_to_keyset_info(row: Vec) -> Result }) } -fn sqlite_row_to_mint_quote(row: Vec) -> Result { +#[instrument(skip_all)] +fn sqlite_row_to_mint_quote( + row: Vec, + payments: Vec, + issueances: Vec, +) -> Result { unpack_into!( let ( - id, amount, unit, request, state, expiry, request_lookup_id, - pubkey, created_time, paid_time, issued_time + id, amount, unit, request, expiry, request_lookup_id, + pubkey, created_time, amount_paid, amount_issued, payment_method, request_lookup_id_kind ) = row ); - let request = column_as_string!(&request); + let request_str = column_as_string!(&request); let request_lookup_id = column_as_nullable_string!(&request_lookup_id).unwrap_or_else(|| { - Bolt11Invoice::from_str(&request) + Bolt11Invoice::from_str(&request_str) .map(|invoice| invoice.payment_hash().to_string()) - .unwrap_or_else(|_| request.clone()) + .unwrap_or_else(|_| request_str.clone()) }); + let request_lookup_id_kind = column_as_string!(request_lookup_id_kind); let pubkey = column_as_nullable_string!(&pubkey) .map(|pk| PublicKey::from_hex(&pk)) .transpose()?; let id = column_as_string!(id); - let amount: u64 = column_as_number!(amount); + let amount: Option = column_as_nullable_number!(amount); + let amount_paid: u64 = column_as_number!(amount_paid); + let amount_issued: u64 = column_as_number!(amount_issued); + let payment_method = column_as_string!(payment_method, PaymentMethod::from_str); - Ok(MintQuote { - id: Uuid::parse_str(&id).map_err(|_| Error::InvalidUuid(id))?, - amount: Amount::from(amount), - unit: column_as_string!(unit, CurrencyUnit::from_str), - request, - state: column_as_string!(state, MintQuoteState::from_str), - expiry: column_as_number!(expiry), - request_lookup_id, + Ok(MintQuote::new( + Some(Uuid::parse_str(&id).map_err(|_| Error::InvalidUuid(id))?), + request_str, + column_as_string!(unit, CurrencyUnit::from_str), + amount.map(Amount::from), + column_as_number!(expiry), + PaymentIdentifier::new(&request_lookup_id_kind, &request_lookup_id) + .map_err(|_| Error::MissingParameter("Payment id".to_string()))?, pubkey, - created_time: column_as_number!(created_time), - paid_time: column_as_nullable_number!(paid_time).map(|p| p), - issued_time: column_as_nullable_number!(issued_time).map(|p| p), - }) + amount_paid.into(), + amount_issued.into(), + payment_method, + column_as_number!(created_time), + payments, + issueances, + )) } fn sqlite_row_to_melt_quote(row: Vec) -> Result { unpack_into!( let ( - id, - unit, - amount, - request, - fee_reserve, - state, - expiry, - payment_preimage, - request_lookup_id, - msat_to_pay, - created_time, - paid_time + id, + unit, + amount, + request, + fee_reserve, + expiry, + state, + payment_preimage, + request_lookup_id, + created_time, + paid_time, + payment_method, + options, + request_lookup_id_kind ) = row ); @@ -1377,27 +1633,59 @@ fn sqlite_row_to_melt_quote(row: Vec) -> Result let amount: u64 = column_as_number!(amount); let fee_reserve: u64 = column_as_number!(fee_reserve); - let request = column_as_string!(&request); + let expiry = column_as_number!(expiry); + let payment_preimage = column_as_nullable_string!(payment_preimage); + let options = column_as_nullable_string!(options); + let options = options.and_then(|o| serde_json::from_str(&o).ok()); + let created_time: i64 = column_as_number!(created_time); + let paid_time = column_as_nullable_number!(paid_time); + let payment_method = PaymentMethod::from_str(&column_as_string!(payment_method))?; + + let state = MeltQuoteState::from_str(&column_as_string!(&state))?; + + let unit = column_as_string!(unit); + let request = column_as_string!(request); + + let mut request_lookup_id_kind = column_as_string!(request_lookup_id_kind); + let request_lookup_id = column_as_nullable_string!(&request_lookup_id).unwrap_or_else(|| { Bolt11Invoice::from_str(&request) .map(|invoice| invoice.payment_hash().to_string()) - .unwrap_or_else(|_| request.clone()) + .unwrap_or_else(|_| { + request_lookup_id_kind = "custom".to_string(); + request.clone() + }) }); - let msat_to_pay: Option = column_as_nullable_number!(msat_to_pay); - Ok(mint::MeltQuote { + let request_lookup_id = PaymentIdentifier::new(&request_lookup_id_kind, &request_lookup_id) + .map_err(|_| Error::MissingParameter("Payment id".to_string()))?; + + let request = match serde_json::from_str(&request) { + Ok(req) => req, + Err(err) => { + tracing::debug!( + "Melt quote from pre migrations defaulting to bolt11 {}.", + err + ); + let bolt11 = Bolt11Invoice::from_str(&request).unwrap(); + MeltPaymentRequest::Bolt11 { bolt11 } + } + }; + + Ok(MeltQuote { id: Uuid::parse_str(&id).map_err(|_| Error::InvalidUuid(id))?, + unit: CurrencyUnit::from_str(&unit)?, amount: Amount::from(amount), - fee_reserve: Amount::from(fee_reserve), - unit: column_as_string!(unit, CurrencyUnit::from_str), request, - payment_preimage: column_as_nullable_string!(payment_preimage), - msat_to_pay: msat_to_pay.map(Amount::from), - state: column_as_string!(state, QuoteState::from_str), - expiry: column_as_number!(expiry), + fee_reserve: Amount::from(fee_reserve), + state, + expiry, + payment_preimage, request_lookup_id, - created_time: column_as_number!(created_time), - paid_time: column_as_nullable_number!(paid_time).map(|p| p), + options, + created_time: created_time as u64, + paid_time, + payment_method, }) } diff --git a/crates/cdk-sqlite/src/wallet/migrations.rs b/crates/cdk-sqlite/src/wallet/migrations.rs index dce9e2c9..0c41bad7 100644 --- a/crates/cdk-sqlite/src/wallet/migrations.rs +++ b/crates/cdk-sqlite/src/wallet/migrations.rs @@ -17,4 +17,5 @@ pub static MIGRATIONS: &[(&str, &str)] = &[ ("20250323152040_wallet_dleq_proofs.sql", include_str!(r#"./migrations/20250323152040_wallet_dleq_proofs.sql"#)), ("20250401120000_add_transactions_table.sql", include_str!(r#"./migrations/20250401120000_add_transactions_table.sql"#)), ("20250616144830_add_keyset_expiry.sql", include_str!(r#"./migrations/20250616144830_add_keyset_expiry.sql"#)), + ("20250707093445_bolt12.sql", include_str!(r#"./migrations/20250707093445_bolt12.sql"#)), ]; diff --git a/crates/cdk-sqlite/src/wallet/migrations/20250707093445_bolt12.sql b/crates/cdk-sqlite/src/wallet/migrations/20250707093445_bolt12.sql new file mode 100644 index 00000000..c191ee59 --- /dev/null +++ b/crates/cdk-sqlite/src/wallet/migrations/20250707093445_bolt12.sql @@ -0,0 +1,58 @@ +ALTER TABLE mint_quote ADD COLUMN amount_paid INTEGER NOT NULL DEFAULT 0; +ALTER TABLE mint_quote ADD COLUMN amount_minted INTEGER NOT NULL DEFAULT 0; +ALTER TABLE mint_quote ADD COLUMN payment_method TEXT NOT NULL DEFAULT 'BOLT11'; + +-- Remove NOT NULL constraint from amount column +PRAGMA foreign_keys=off; +CREATE TABLE mint_quote_new ( + id TEXT PRIMARY KEY, + mint_url TEXT NOT NULL, + payment_method TEXT NOT NULL DEFAULT 'bolt11', + amount INTEGER, + unit TEXT NOT NULL, + request TEXT NOT NULL, + state TEXT NOT NULL, + expiry INTEGER NOT NULL, + amount_paid INTEGER NOT NULL DEFAULT 0, + amount_issued INTEGER NOT NULL DEFAULT 0, + secret_key TEXT +); + +-- Explicitly specify columns for proper mapping +INSERT INTO mint_quote_new ( + id, + mint_url, + payment_method, + amount, + unit, + request, + state, + expiry, + amount_paid, + amount_issued, + secret_key +) +SELECT + id, + mint_url, + 'bolt11', -- Default value for the new payment_method column + amount, + unit, + request, + state, + expiry, + 0, -- Default value for amount_paid + 0, -- Default value for amount_minted + secret_key +FROM mint_quote; + +DROP TABLE mint_quote; +ALTER TABLE mint_quote_new RENAME TO mint_quote; +PRAGMA foreign_keys=on; + + +-- Set amount_paid equal to amount for quotes with PAID or ISSUED state +UPDATE mint_quote SET amount_paid = amount WHERE state = 'PAID' OR state = 'ISSUED'; + +-- Set amount_issued equal to amount for quotes with ISSUED state +UPDATE mint_quote SET amount_issued = amount WHERE state = 'ISSUED'; diff --git a/crates/cdk-sqlite/src/wallet/mod.rs b/crates/cdk-sqlite/src/wallet/mod.rs index 74c57b36..5e16e7fc 100644 --- a/crates/cdk-sqlite/src/wallet/mod.rs +++ b/crates/cdk-sqlite/src/wallet/mod.rs @@ -14,8 +14,8 @@ use cdk_common::nuts::{MeltQuoteState, MintQuoteState}; use cdk_common::secret::Secret; use cdk_common::wallet::{self, MintQuote, Transaction, TransactionDirection, TransactionId}; use cdk_common::{ - database, Amount, CurrencyUnit, Id, KeySet, KeySetInfo, Keys, MintInfo, Proof, ProofDleq, - PublicKey, SecretKey, SpendingConditions, State, + database, Amount, CurrencyUnit, Id, KeySet, KeySetInfo, Keys, MintInfo, PaymentMethod, Proof, + ProofDleq, PublicKey, SecretKey, SpendingConditions, State, }; use error::Error; use tracing::instrument; @@ -376,9 +376,9 @@ ON CONFLICT(mint_url) DO UPDATE SET Statement::new( r#" INSERT INTO mint_quote -(id, mint_url, amount, unit, request, state, expiry, secret_key) +(id, mint_url, amount, unit, request, state, expiry, secret_key, payment_method, amount_issued, amount_paid) VALUES -(:id, :mint_url, :amount, :unit, :request, :state, :expiry, :secret_key) +(:id, :mint_url, :amount, :unit, :request, :state, :expiry, :secret_key, :payment_method, :amount_issued, :amount_paid) ON CONFLICT(id) DO UPDATE SET mint_url = excluded.mint_url, amount = excluded.amount, @@ -386,18 +386,24 @@ ON CONFLICT(id) DO UPDATE SET request = excluded.request, state = excluded.state, expiry = excluded.expiry, - secret_key = excluded.secret_key + secret_key = excluded.secret_key, + payment_method = excluded.payment_method, + amount_issued = excluded.amount_issued, + amount_paid = excluded.amount_paid ; "#, ) .bind(":id", quote.id.to_string()) .bind(":mint_url", quote.mint_url.to_string()) - .bind(":amount", u64::from(quote.amount) as i64) + .bind(":amount", quote.amount.map(|a| a.to_i64())) .bind(":unit", quote.unit.to_string()) .bind(":request", quote.request) .bind(":state", quote.state.to_string()) .bind(":expiry", quote.expiry as i64) .bind(":secret_key", quote.secret_key.map(|p| p.to_string())) + .bind(":payment_method", quote.payment_method.to_string()) + .bind(":amount_issued", quote.amount_issued.to_i64()) + .bind(":amount_paid", quote.amount_paid.to_i64()) .execute(&self.pool.get().map_err(Error::Pool)?) .map_err(Error::Sqlite)?; @@ -416,7 +422,10 @@ ON CONFLICT(id) DO UPDATE SET request, state, expiry, - secret_key + secret_key, + payment_method, + amount_issued, + amount_paid FROM mint_quote WHERE @@ -950,16 +959,24 @@ fn sqlite_row_to_mint_quote(row: Vec) -> Result { request, state, expiry, - secret_key + secret_key, + row_method, + row_amount_minted, + row_amount_paid ) = row ); - let amount: u64 = column_as_number!(amount); + let amount: Option = column_as_nullable_number!(amount); + + let amount_paid: u64 = column_as_number!(row_amount_paid); + let amount_minted: u64 = column_as_number!(row_amount_minted); + let payment_method = + PaymentMethod::from_str(&column_as_string!(row_method)).map_err(Error::from)?; Ok(MintQuote { id: column_as_string!(id), mint_url: column_as_string!(mint_url, MintUrl::from_str), - amount: Amount::from(amount), + amount: amount.and_then(Amount::from_i64), unit: column_as_string!(unit, CurrencyUnit::from_str), request: column_as_string!(request), state: column_as_string!(state, MintQuoteState::from_str), @@ -967,6 +984,9 @@ fn sqlite_row_to_mint_quote(row: Vec) -> Result { secret_key: column_as_nullable_string!(secret_key) .map(|v| SecretKey::from_str(&v)) .transpose()?, + payment_method, + amount_issued: amount_minted.into(), + amount_paid: amount_paid.into(), }) } diff --git a/crates/cdk/Cargo.toml b/crates/cdk/Cargo.toml index 8b9625f8..73d3a1e7 100644 --- a/crates/cdk/Cargo.toml +++ b/crates/cdk/Cargo.toml @@ -28,6 +28,7 @@ async-trait.workspace = true anyhow.workspace = true bitcoin.workspace = true ciborium.workspace = true +lightning.workspace = true lightning-invoice.workspace = true regex.workspace = true reqwest = { workspace = true, optional = true } diff --git a/crates/cdk/src/mint/builder.rs b/crates/cdk/src/mint/builder.rs index 4f85e057..fbdc9641 100644 --- a/crates/cdk/src/mint/builder.rs +++ b/crates/cdk/src/mint/builder.rs @@ -210,32 +210,30 @@ impl MintBuilder { self.mint_info.nuts.nut15 = mpp; } - if method == PaymentMethod::Bolt11 { - let mint_method_settings = MintMethodSettings { - method: method.clone(), - unit: unit.clone(), - min_amount: Some(limits.mint_min), - max_amount: Some(limits.mint_max), - options: Some(MintMethodOptions::Bolt11 { - description: settings.invoice_description, - }), - }; + let mint_method_settings = MintMethodSettings { + method: method.clone(), + unit: unit.clone(), + min_amount: Some(limits.mint_min), + max_amount: Some(limits.mint_max), + options: Some(MintMethodOptions::Bolt11 { + description: settings.invoice_description, + }), + }; - self.mint_info.nuts.nut04.methods.push(mint_method_settings); - self.mint_info.nuts.nut04.disabled = false; + self.mint_info.nuts.nut04.methods.push(mint_method_settings); + self.mint_info.nuts.nut04.disabled = false; - let melt_method_settings = MeltMethodSettings { - method, - unit, - min_amount: Some(limits.melt_min), - max_amount: Some(limits.melt_max), - options: Some(MeltMethodOptions::Bolt11 { - amountless: settings.amountless, - }), - }; - self.mint_info.nuts.nut05.methods.push(melt_method_settings); - self.mint_info.nuts.nut05.disabled = false; - } + let melt_method_settings = MeltMethodSettings { + method, + unit, + min_amount: Some(limits.melt_min), + max_amount: Some(limits.melt_max), + options: Some(MeltMethodOptions::Bolt11 { + amountless: settings.amountless, + }), + }; + self.mint_info.nuts.nut05.methods.push(melt_method_settings); + self.mint_info.nuts.nut05.disabled = false; ln.insert(ln_key.clone(), ln_backend); diff --git a/crates/cdk/src/mint/issue/issue_nut04.rs b/crates/cdk/src/mint/issue/issue_nut04.rs deleted file mode 100644 index 4b159b8d..00000000 --- a/crates/cdk/src/mint/issue/issue_nut04.rs +++ /dev/null @@ -1,301 +0,0 @@ -use cdk_common::payment::Bolt11Settings; -use tracing::instrument; -use uuid::Uuid; - -use crate::mint::{ - CurrencyUnit, MintQuote, MintQuoteBolt11Request, MintQuoteBolt11Response, MintQuoteState, - MintRequest, MintResponse, NotificationPayload, PublicKey, Verification, -}; -use crate::nuts::PaymentMethod; -use crate::util::unix_time; -use crate::{ensure_cdk, Amount, Error, Mint}; - -impl Mint { - /// Checks that minting is enabled, request is supported unit and within range - async fn check_mint_request_acceptable( - &self, - amount: Amount, - unit: &CurrencyUnit, - ) -> Result<(), Error> { - let mint_info = self.localstore.get_mint_info().await?; - let nut04 = &mint_info.nuts.nut04; - - ensure_cdk!(!nut04.disabled, Error::MintingDisabled); - - let settings = nut04 - .get_settings(unit, &PaymentMethod::Bolt11) - .ok_or(Error::UnsupportedUnit)?; - - let is_above_max = settings - .max_amount - .is_some_and(|max_amount| amount > max_amount); - let is_below_min = settings - .min_amount - .is_some_and(|min_amount| amount < min_amount); - let is_out_of_range = is_above_max || is_below_min; - - ensure_cdk!( - !is_out_of_range, - Error::AmountOutofLimitRange( - settings.min_amount.unwrap_or_default(), - settings.max_amount.unwrap_or_default(), - amount, - ) - ); - - Ok(()) - } - - /// Create new mint bolt11 quote - #[instrument(skip_all)] - pub async fn get_mint_bolt11_quote( - &self, - mint_quote_request: MintQuoteBolt11Request, - ) -> Result, Error> { - let MintQuoteBolt11Request { - amount, - unit, - description, - pubkey, - } = mint_quote_request; - - self.check_mint_request_acceptable(amount, &unit).await?; - - let ln = self.get_payment_processor(unit.clone(), PaymentMethod::Bolt11)?; - - let mint_ttl = self.localstore.get_quote_ttl().await?.mint_ttl; - - let quote_expiry = unix_time() + mint_ttl; - - let settings = ln.get_settings().await?; - let settings: Bolt11Settings = serde_json::from_value(settings)?; - - if description.is_some() && !settings.invoice_description { - tracing::error!("Backend does not support invoice description"); - return Err(Error::InvoiceDescriptionUnsupported); - } - - let create_invoice_response = ln - .create_incoming_payment_request( - amount, - &unit, - description.unwrap_or("".to_string()), - Some(quote_expiry), - ) - .await - .map_err(|err| { - tracing::error!("Could not create invoice: {}", err); - Error::InvalidPaymentRequest - })?; - - let quote = MintQuote::new( - create_invoice_response.request.to_string(), - unit.clone(), - amount, - create_invoice_response.expiry.unwrap_or(0), - create_invoice_response.request_lookup_id.clone(), - pubkey, - ); - - tracing::debug!( - "New mint quote {} for {} {} with request id {}", - quote.id, - amount, - unit, - create_invoice_response.request_lookup_id, - ); - - let mut tx = self.localstore.begin_transaction().await?; - tx.add_or_replace_mint_quote(quote.clone()).await?; - tx.commit().await?; - - let quote: MintQuoteBolt11Response = quote.into(); - - self.pubsub_manager - .broadcast(NotificationPayload::MintQuoteBolt11Response(quote.clone())); - - Ok(quote) - } - - /// Check mint quote - #[instrument(skip(self))] - pub async fn check_mint_quote( - &self, - quote_id: &Uuid, - ) -> Result, Error> { - let mut tx = self.localstore.begin_transaction().await?; - let mut mint_quote = tx - .get_mint_quote(quote_id) - .await? - .ok_or(Error::UnknownQuote)?; - - // Since the pending state is not part of the NUT it should not be part of the - // response. In practice the wallet should not be checking the state of - // a quote while waiting for the mint response. - if mint_quote.state == MintQuoteState::Unpaid { - self.check_mint_quote_paid(tx, &mut mint_quote) - .await? - .commit() - .await?; - } - - Ok(MintQuoteBolt11Response { - quote: mint_quote.id, - request: mint_quote.request, - state: mint_quote.state, - expiry: Some(mint_quote.expiry), - pubkey: mint_quote.pubkey, - amount: Some(mint_quote.amount), - unit: Some(mint_quote.unit.clone()), - }) - } - - /// Get mint quotes - #[instrument(skip_all)] - pub async fn mint_quotes(&self) -> Result, Error> { - let quotes = self.localstore.get_mint_quotes().await?; - Ok(quotes) - } - - /// Remove mint quote - #[instrument(skip_all)] - pub async fn remove_mint_quote(&self, quote_id: &Uuid) -> Result<(), Error> { - let mut tx = self.localstore.begin_transaction().await?; - tx.remove_mint_quote(quote_id).await?; - tx.commit().await?; - - Ok(()) - } - - /// Flag mint quote as paid - #[instrument(skip_all)] - pub async fn pay_mint_quote_for_request_id( - &self, - request_lookup_id: &str, - ) -> Result<(), Error> { - if let Ok(Some(mint_quote)) = self - .localstore - .get_mint_quote_by_request_lookup_id(request_lookup_id) - .await - { - self.pay_mint_quote(&mint_quote).await?; - } - Ok(()) - } - - /// Mark mint quote as paid - #[instrument(skip_all)] - pub async fn pay_mint_quote(&self, mint_quote: &MintQuote) -> Result<(), Error> { - tracing::debug!( - "Received payment notification for mint quote {}", - mint_quote.id - ); - if mint_quote.state != MintQuoteState::Issued && mint_quote.state != MintQuoteState::Paid { - let mut tx = self.localstore.begin_transaction().await?; - tx.update_mint_quote_state(&mint_quote.id, MintQuoteState::Paid) - .await?; - tx.commit().await?; - } else { - tracing::debug!( - "{} Quote already {} continuing", - mint_quote.id, - mint_quote.state - ); - } - - self.pubsub_manager - .mint_quote_bolt11_status(mint_quote.clone(), MintQuoteState::Paid); - - Ok(()) - } - - /// Process mint request - #[instrument(skip_all)] - pub async fn process_mint_request( - &self, - mint_request: MintRequest, - ) -> Result { - let mut tx = self.localstore.begin_transaction().await?; - - let mut mint_quote = tx - .get_mint_quote(&mint_request.quote) - .await? - .ok_or(Error::UnknownQuote)?; - - let mut tx = if mint_quote.state == MintQuoteState::Unpaid { - self.check_mint_quote_paid(tx, &mut mint_quote).await? - } else { - tx - }; - - match mint_quote.state { - MintQuoteState::Unpaid => { - return Err(Error::UnpaidQuote); - } - MintQuoteState::Pending => { - return Err(Error::PendingQuote); - } - MintQuoteState::Issued => { - return Err(Error::IssuedQuote); - } - MintQuoteState::Paid => (), - } - - // If the there is a public key provoided in mint quote request - // verify the signature is provided for the mint request - if let Some(pubkey) = mint_quote.pubkey { - mint_request.verify_signature(pubkey)?; - } - - let Verification { amount, unit } = - match self.verify_outputs(&mut tx, &mint_request.outputs).await { - Ok(verification) => verification, - Err(err) => { - tracing::debug!("Could not verify mint outputs"); - return Err(err); - } - }; - - // We check the total value of blinded messages == mint quote - if amount != mint_quote.amount { - return Err(Error::TransactionUnbalanced( - mint_quote.amount.into(), - mint_request.total_amount()?.into(), - 0, - )); - } - - let unit = unit.ok_or(Error::UnsupportedUnit)?; - ensure_cdk!(unit == mint_quote.unit, Error::UnsupportedUnit); - - let mut blind_signatures = Vec::with_capacity(mint_request.outputs.len()); - - for blinded_message in mint_request.outputs.iter() { - let blind_signature = self.blind_sign(blinded_message.clone()).await?; - blind_signatures.push(blind_signature); - } - - tx.add_blind_signatures( - &mint_request - .outputs - .iter() - .map(|p| p.blinded_secret) - .collect::>(), - &blind_signatures, - Some(mint_request.quote), - ) - .await?; - - tx.update_mint_quote_state(&mint_request.quote, MintQuoteState::Issued) - .await?; - - tx.commit().await?; - - self.pubsub_manager - .mint_quote_bolt11_status(mint_quote, MintQuoteState::Issued); - - Ok(MintResponse { - signatures: blind_signatures, - }) - } -} diff --git a/crates/cdk/src/mint/issue/mod.rs b/crates/cdk/src/mint/issue/mod.rs index 9c3f8443..0cf772f0 100644 --- a/crates/cdk/src/mint/issue/mod.rs +++ b/crates/cdk/src/mint/issue/mod.rs @@ -1,3 +1,601 @@ +use cdk_common::mint::MintQuote; +use cdk_common::payment::{ + Bolt11IncomingPaymentOptions, Bolt11Settings, Bolt12IncomingPaymentOptions, + IncomingPaymentOptions, WaitPaymentResponse, +}; +use cdk_common::util::unix_time; +use cdk_common::{ + database, ensure_cdk, Amount, CurrencyUnit, Error, MintQuoteBolt11Request, + MintQuoteBolt11Response, MintQuoteBolt12Request, MintQuoteBolt12Response, MintQuoteState, + MintRequest, MintResponse, NotificationPayload, PaymentMethod, PublicKey, +}; +use tracing::instrument; +use uuid::Uuid; + +use crate::mint::Verification; +use crate::Mint; + #[cfg(feature = "auth")] mod auth; -mod issue_nut04; + +/// Request for creating a mint quote +/// +/// This enum represents the different types of payment requests that can be used +/// to create a mint quote. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MintQuoteRequest { + /// Lightning Network BOLT11 invoice request + Bolt11(MintQuoteBolt11Request), + /// Lightning Network BOLT12 offer request + Bolt12(MintQuoteBolt12Request), +} + +impl From for MintQuoteRequest { + fn from(request: MintQuoteBolt11Request) -> Self { + MintQuoteRequest::Bolt11(request) + } +} + +impl From for MintQuoteRequest { + fn from(request: MintQuoteBolt12Request) -> Self { + MintQuoteRequest::Bolt12(request) + } +} + +/// Response for a mint quote request +/// +/// This enum represents the different types of payment responses that can be returned +/// when creating a mint quote. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MintQuoteResponse { + /// Lightning Network BOLT11 invoice response + Bolt11(MintQuoteBolt11Response), + /// Lightning Network BOLT12 offer response + Bolt12(MintQuoteBolt12Response), +} + +impl TryFrom for MintQuoteBolt11Response { + type Error = Error; + + fn try_from(response: MintQuoteResponse) -> Result { + match response { + MintQuoteResponse::Bolt11(bolt11_response) => Ok(bolt11_response), + _ => Err(Error::InvalidPaymentMethod), + } + } +} + +impl TryFrom for MintQuoteBolt12Response { + type Error = Error; + + fn try_from(response: MintQuoteResponse) -> Result { + match response { + MintQuoteResponse::Bolt12(bolt12_response) => Ok(bolt12_response), + _ => Err(Error::InvalidPaymentMethod), + } + } +} + +impl TryFrom for MintQuoteResponse { + type Error = Error; + + fn try_from(quote: MintQuote) -> Result { + match quote.payment_method { + PaymentMethod::Bolt11 => { + let bolt11_response: MintQuoteBolt11Response = quote.into(); + Ok(MintQuoteResponse::Bolt11(bolt11_response)) + } + PaymentMethod::Bolt12 => { + let bolt12_response = MintQuoteBolt12Response::try_from(quote)?; + Ok(MintQuoteResponse::Bolt12(bolt12_response)) + } + PaymentMethod::Custom(_) => Err(Error::InvalidPaymentMethod), + } + } +} + +impl From for MintQuoteBolt11Response { + fn from(response: MintQuoteResponse) -> Self { + match response { + MintQuoteResponse::Bolt11(bolt11_response) => MintQuoteBolt11Response { + quote: bolt11_response.quote.to_string(), + state: bolt11_response.state, + request: bolt11_response.request, + expiry: bolt11_response.expiry, + pubkey: bolt11_response.pubkey, + amount: bolt11_response.amount, + unit: bolt11_response.unit, + }, + _ => panic!("Expected Bolt11 response"), + } + } +} + +impl Mint { + /// Validates that a mint request meets all requirements + /// + /// Checks that: + /// - Minting is enabled for the requested payment method + /// - The currency unit is supported + /// - The amount (if provided) is within the allowed range for the payment method + /// + /// # Arguments + /// * `amount` - Optional amount to validate + /// * `unit` - Currency unit for the request + /// * `payment_method` - Payment method (Bolt11, Bolt12, etc.) + /// + /// # Returns + /// * `Ok(())` if the request is acceptable + /// * `Error` if any validation fails + pub async fn check_mint_request_acceptable( + &self, + amount: Option, + unit: &CurrencyUnit, + payment_method: &PaymentMethod, + ) -> Result<(), Error> { + let mint_info = self.localstore.get_mint_info().await?; + + let nut04 = &mint_info.nuts.nut04; + ensure_cdk!(!nut04.disabled, Error::MintingDisabled); + + let disabled = nut04.disabled; + + ensure_cdk!(!disabled, Error::MintingDisabled); + + let settings = nut04 + .get_settings(unit, payment_method) + .ok_or(Error::UnsupportedUnit)?; + + let min_amount = settings.min_amount; + let max_amount = settings.max_amount; + + // Check amount limits if an amount is provided + if let Some(amount) = amount { + let is_above_max = max_amount.is_some_and(|max_amount| amount > max_amount); + let is_below_min = min_amount.is_some_and(|min_amount| amount < min_amount); + let is_out_of_range = is_above_max || is_below_min; + + ensure_cdk!( + !is_out_of_range, + Error::AmountOutofLimitRange( + min_amount.unwrap_or_default(), + max_amount.unwrap_or_default(), + amount, + ) + ); + } + + Ok(()) + } + + /// Creates a new mint quote for the specified payment request + /// + /// Handles both Bolt11 and Bolt12 payment requests by: + /// 1. Validating the request parameters + /// 2. Creating an appropriate payment request via the payment processor + /// 3. Storing the quote in the database + /// 4. Broadcasting a notification about the new quote + /// + /// # Arguments + /// * `mint_quote_request` - The request containing payment details + /// + /// # Returns + /// * `MintQuoteResponse` - Response with payment details if successful + /// * `Error` - If the request is invalid or payment creation fails + #[instrument(skip_all)] + pub async fn get_mint_quote( + &self, + mint_quote_request: MintQuoteRequest, + ) -> Result { + let unit: CurrencyUnit; + let amount; + let pubkey; + let payment_method; + + let create_invoice_response = match mint_quote_request { + MintQuoteRequest::Bolt11(bolt11_request) => { + unit = bolt11_request.unit; + amount = Some(bolt11_request.amount); + pubkey = bolt11_request.pubkey; + payment_method = PaymentMethod::Bolt11; + + self.check_mint_request_acceptable( + Some(bolt11_request.amount), + &unit, + &payment_method, + ) + .await?; + + let ln = self.get_payment_processor(unit.clone(), PaymentMethod::Bolt11)?; + + let mint_ttl = self.localstore.get_quote_ttl().await?.mint_ttl; + + let quote_expiry = unix_time() + mint_ttl; + + let settings = ln.get_settings().await?; + let settings: Bolt11Settings = serde_json::from_value(settings)?; + + let description = bolt11_request.description; + + if description.is_some() && !settings.invoice_description { + tracing::error!("Backend does not support invoice description"); + return Err(Error::InvoiceDescriptionUnsupported); + } + + let bolt11_options = Bolt11IncomingPaymentOptions { + description, + amount: bolt11_request.amount, + unix_expiry: Some(quote_expiry), + }; + + let incoming_options = IncomingPaymentOptions::Bolt11(bolt11_options); + + ln.create_incoming_payment_request(&unit, incoming_options) + .await + .map_err(|err| { + tracing::error!("Could not create invoice: {}", err); + Error::InvalidPaymentRequest + })? + } + MintQuoteRequest::Bolt12(bolt12_request) => { + unit = bolt12_request.unit; + amount = bolt12_request.amount; + pubkey = Some(bolt12_request.pubkey); + payment_method = PaymentMethod::Bolt12; + + self.check_mint_request_acceptable(amount, &unit, &payment_method) + .await?; + + let ln = self.get_payment_processor(unit.clone(), payment_method.clone())?; + + let description = bolt12_request.description; + + let mint_ttl = self.localstore.get_quote_ttl().await?.mint_ttl; + + let expiry = unix_time() + mint_ttl; + + let bolt12_options = Bolt12IncomingPaymentOptions { + description, + amount, + unix_expiry: Some(expiry), + }; + + let incoming_options = IncomingPaymentOptions::Bolt12(Box::new(bolt12_options)); + + ln.create_incoming_payment_request(&unit, incoming_options) + .await + .map_err(|err| { + tracing::error!("Could not create invoice: {}", err); + Error::InvalidPaymentRequest + })? + } + }; + + let quote = MintQuote::new( + None, + create_invoice_response.request.to_string(), + unit.clone(), + amount, + create_invoice_response.expiry.unwrap_or(0), + create_invoice_response.request_lookup_id.clone(), + pubkey, + Amount::ZERO, + Amount::ZERO, + payment_method.clone(), + unix_time(), + vec![], + vec![], + ); + + tracing::debug!( + "New {} mint quote {} for {:?} {} with request id {:?}", + payment_method, + quote.id, + amount, + unit, + create_invoice_response.request_lookup_id, + ); + + let mut tx = self.localstore.begin_transaction().await?; + tx.add_mint_quote(quote.clone()).await?; + tx.commit().await?; + + match payment_method { + PaymentMethod::Bolt11 => { + let res: MintQuoteBolt11Response = quote.clone().into(); + self.pubsub_manager + .broadcast(NotificationPayload::MintQuoteBolt11Response(res)); + } + PaymentMethod::Bolt12 => { + let res: MintQuoteBolt12Response = quote.clone().try_into()?; + self.pubsub_manager + .broadcast(NotificationPayload::MintQuoteBolt12Response(res)); + } + PaymentMethod::Custom(_) => {} + } + + quote.try_into() + } + + /// Retrieves all mint quotes from the database + /// + /// # Returns + /// * `Vec` - List of all mint quotes + /// * `Error` if database access fails + #[instrument(skip_all)] + pub async fn mint_quotes(&self) -> Result, Error> { + let quotes = self.localstore.get_mint_quotes().await?; + Ok(quotes) + } + + /// Removes a mint quote from the database + /// + /// # Arguments + /// * `quote_id` - The UUID of the quote to remove + /// + /// # Returns + /// * `Ok(())` if removal was successful + /// * `Error` if the quote doesn't exist or removal fails + #[instrument(skip_all)] + pub async fn remove_mint_quote(&self, quote_id: &Uuid) -> Result<(), Error> { + let mut tx = self.localstore.begin_transaction().await?; + tx.remove_mint_quote(quote_id).await?; + tx.commit().await?; + + Ok(()) + } + + /// Marks a mint quote as paid based on the payment request ID + /// + /// Looks up the mint quote by the payment request ID and marks it as paid + /// if found. + /// + /// # Arguments + /// * `wait_payment_response` - Payment response containing payment details + /// + /// # Returns + /// * `Ok(())` if the quote was found and updated + /// * `Error` if the update fails + #[instrument(skip_all)] + pub async fn pay_mint_quote_for_request_id( + &self, + wait_payment_response: WaitPaymentResponse, + ) -> Result<(), Error> { + if wait_payment_response.payment_amount == Amount::ZERO { + tracing::warn!( + "Received payment response with 0 amount with payment id {}.", + wait_payment_response.payment_id + ); + + return Err(Error::AmountUndefined); + } + + let mut tx = self.localstore.begin_transaction().await?; + + if let Ok(Some(mint_quote)) = tx + .get_mint_quote_by_request_lookup_id(&wait_payment_response.payment_identifier) + .await + { + self.pay_mint_quote(&mut tx, &mint_quote, wait_payment_response) + .await?; + } else { + tracing::warn!( + "Could not get request for request lookup id {:?}.", + wait_payment_response.payment_identifier + ); + } + + tx.commit().await?; + + Ok(()) + } + + /// Marks a specific mint quote as paid + /// + /// Updates the mint quote with payment information and broadcasts + /// a notification about the payment status change. + /// + /// # Arguments + /// * `mint_quote` - The mint quote to mark as paid + /// * `wait_payment_response` - Payment response containing payment details + /// + /// # Returns + /// * `Ok(())` if the update was successful + /// * `Error` if the update fails + #[instrument(skip_all)] + pub async fn pay_mint_quote( + &self, + tx: &mut Box + Send + Sync + '_>, + mint_quote: &MintQuote, + wait_payment_response: WaitPaymentResponse, + ) -> Result<(), Error> { + tracing::debug!( + "Received payment notification of {} for mint quote {} with payment id {}", + wait_payment_response.payment_amount, + mint_quote.id, + wait_payment_response.payment_id + ); + + let quote_state = mint_quote.state(); + if !mint_quote + .payment_ids() + .contains(&&wait_payment_response.payment_id) + { + if mint_quote.payment_method == PaymentMethod::Bolt11 + && (quote_state == MintQuoteState::Issued || quote_state == MintQuoteState::Paid) + { + tracing::info!("Received payment notification for already seen payment."); + } else { + tx.increment_mint_quote_amount_paid( + &mint_quote.id, + wait_payment_response.payment_amount, + wait_payment_response.payment_id, + ) + .await?; + + self.pubsub_manager + .mint_quote_bolt11_status(mint_quote.clone(), MintQuoteState::Paid); + } + } else { + tracing::info!("Received payment notification for already seen payment."); + } + + Ok(()) + } + + /// Checks the status of a mint quote and updates it if necessary + /// + /// If the quote is unpaid, this will check if payment has been received. + /// Returns the current state of the quote. + /// + /// # Arguments + /// * `quote_id` - The UUID of the quote to check + /// + /// # Returns + /// * `MintQuoteResponse` - The current state of the quote + /// * `Error` if the quote doesn't exist or checking fails + #[instrument(skip(self))] + pub async fn check_mint_quote(&self, quote_id: &Uuid) -> Result { + let mut quote = self + .localstore + .get_mint_quote(quote_id) + .await? + .ok_or(Error::UnknownQuote)?; + + self.check_mint_quote_paid(&mut quote).await?; + + quote.try_into() + } + + /// Processes a mint request to issue new tokens + /// + /// This function: + /// 1. Verifies the mint quote exists and is paid + /// 2. Validates the request signature if a pubkey was provided + /// 3. Verifies the outputs match the expected amount + /// 4. Signs the blinded messages + /// 5. Updates the quote status + /// 6. Broadcasts a notification about the status change + /// + /// # Arguments + /// * `mint_request` - The mint request containing blinded outputs to sign + /// + /// # Returns + /// * `MintBolt11Response` - Response containing blind signatures + /// * `Error` if validation fails or signing fails + #[instrument(skip_all)] + pub async fn process_mint_request( + &self, + mint_request: MintRequest, + ) -> Result { + let mut mint_quote = self + .localstore + .get_mint_quote(&mint_request.quote) + .await? + .ok_or(Error::UnknownQuote)?; + + self.check_mint_quote_paid(&mut mint_quote).await?; + + let mut tx = self.localstore.begin_transaction().await?; + + let mint_quote = tx + .get_mint_quote(&mint_request.quote) + .await? + .ok_or(Error::UnknownQuote)?; + + match mint_quote.state() { + MintQuoteState::Unpaid => { + return Err(Error::UnpaidQuote); + } + MintQuoteState::Issued => { + if mint_quote.payment_method == PaymentMethod::Bolt12 + && mint_quote.amount_paid() > mint_quote.amount_issued() + { + tracing::warn!("Mint quote should state should have been set to issued upon new payment. Something isn't right. Stopping mint"); + } + + return Err(Error::IssuedQuote); + } + MintQuoteState::Paid => (), + } + + if mint_quote.payment_method == PaymentMethod::Bolt12 && mint_quote.pubkey.is_none() { + tracing::warn!("Bolt12 mint quote created without pubkey"); + return Err(Error::SignatureMissingOrInvalid); + } + + let mint_amount = match mint_quote.payment_method { + PaymentMethod::Bolt11 => mint_quote.amount.ok_or(Error::AmountUndefined)?, + PaymentMethod::Bolt12 => { + if mint_quote.amount_issued() > mint_quote.amount_paid() { + tracing::error!( + "Quote state should not be issued if issued {} is > paid {}.", + mint_quote.amount_issued(), + mint_quote.amount_paid() + ); + return Err(Error::UnpaidQuote); + } + mint_quote.amount_paid() - mint_quote.amount_issued() + } + _ => return Err(Error::UnsupportedPaymentMethod), + }; + + // If the there is a public key provoided in mint quote request + // verify the signature is provided for the mint request + if let Some(pubkey) = mint_quote.pubkey { + mint_request.verify_signature(pubkey)?; + } + + let Verification { amount, unit } = + match self.verify_outputs(&mut tx, &mint_request.outputs).await { + Ok(verification) => verification, + Err(err) => { + tracing::debug!("Could not verify mint outputs"); + + return Err(err); + } + }; + + // We check the total value of blinded messages == mint quote + if amount != mint_amount { + return Err(Error::TransactionUnbalanced( + mint_amount.into(), + mint_request.total_amount()?.into(), + 0, + )); + } + + let unit = unit.ok_or(Error::UnsupportedUnit).unwrap(); + ensure_cdk!(unit == mint_quote.unit, Error::UnsupportedUnit); + + let mut blind_signatures = Vec::with_capacity(mint_request.outputs.len()); + + for blinded_message in mint_request.outputs.iter() { + let blind_signature = self.blind_sign(blinded_message.clone()).await?; + blind_signatures.push(blind_signature); + } + + tx.add_blind_signatures( + &mint_request + .outputs + .iter() + .map(|p| p.blinded_secret) + .collect::>(), + &blind_signatures, + Some(mint_request.quote), + ) + .await?; + + tx.increment_mint_quote_amount_issued(&mint_request.quote, mint_request.total_amount()?) + .await?; + + tx.commit().await?; + + self.pubsub_manager + .mint_quote_bolt11_status(mint_quote, MintQuoteState::Issued); + + Ok(MintResponse { + signatures: blind_signatures, + }) + } +} diff --git a/crates/cdk/src/mint/ln.rs b/crates/cdk/src/mint/ln.rs index d23122a0..5ffb3e4a 100644 --- a/crates/cdk/src/mint/ln.rs +++ b/crates/cdk/src/mint/ln.rs @@ -1,21 +1,31 @@ +use cdk_common::amount::to_unit; use cdk_common::common::PaymentProcessorKey; -use cdk_common::database::{self, MintTransaction}; use cdk_common::mint::MintQuote; -use cdk_common::MintQuoteState; +use cdk_common::util::unix_time; +use cdk_common::{MintQuoteState, PaymentMethod}; +use tracing::instrument; use super::Mint; use crate::Error; impl Mint { /// Check the status of an ln payment for a quote - pub async fn check_mint_quote_paid( - &self, - tx: Box + Send + Sync + '_>, - quote: &mut MintQuote, - ) -> Result + Send + Sync + '_>, Error> { + #[instrument(skip_all)] + pub async fn check_mint_quote_paid(&self, quote: &mut MintQuote) -> Result<(), Error> { + let state = quote.state(); + + // We can just return here and do not need to check with ln node. + // If quote is issued it is already in a final state, + // If it is paid ln node will only tell us what we already know + if quote.payment_method == PaymentMethod::Bolt11 + && (state == MintQuoteState::Issued || state == MintQuoteState::Paid) + { + return Ok(()); + } + let ln = match self.ln.get(&PaymentProcessorKey::new( quote.unit.clone(), - cdk_common::PaymentMethod::Bolt11, + quote.payment_method.clone(), )) { Some(ln) => ln, None => { @@ -25,23 +35,30 @@ impl Mint { } }; - tx.commit().await?; - let ln_status = ln .check_incoming_payment_status("e.request_lookup_id) .await?; let mut tx = self.localstore.begin_transaction().await?; - if ln_status != quote.state && quote.state != MintQuoteState::Issued { - tx.update_mint_quote_state("e.id, ln_status).await?; + for payment in ln_status { + if !quote.payment_ids().contains(&&payment.payment_id) { + tracing::debug!("Found payment for quote {} when checking.", quote.id); + let amount_paid = to_unit(payment.payment_amount, &payment.unit, "e.unit)?; - quote.state = ln_status; + quote.increment_amount_paid(amount_paid)?; + quote.add_payment(amount_paid, payment.payment_id.clone(), unix_time())?; - self.pubsub_manager - .mint_quote_bolt11_status(quote.clone(), ln_status); + tx.increment_mint_quote_amount_paid("e.id, amount_paid, payment.payment_id) + .await?; + + self.pubsub_manager + .mint_quote_bolt11_status(quote.clone(), MintQuoteState::Paid); + } } - Ok(tx) + tx.commit().await?; + + Ok(()) } } diff --git a/crates/cdk/src/mint/melt.rs b/crates/cdk/src/mint/melt.rs index 544bab97..d0792ca0 100644 --- a/crates/cdk/src/mint/melt.rs +++ b/crates/cdk/src/mint/melt.rs @@ -1,11 +1,18 @@ use std::str::FromStr; use anyhow::bail; +use cdk_common::amount::amount_for_offer; use cdk_common::database::{self, MintTransaction}; +use cdk_common::melt::MeltQuoteRequest; +use cdk_common::mint::MeltPaymentRequest; use cdk_common::nut00::ProofsMethods; use cdk_common::nut05::MeltMethodOptions; -use cdk_common::MeltOptions; -use lightning_invoice::Bolt11Invoice; +use cdk_common::payment::{ + Bolt11OutgoingPaymentOptions, Bolt12OutgoingPaymentOptions, OutgoingPaymentOptions, + PaymentQuoteOptions, +}; +use cdk_common::{MeltOptions, MeltQuoteBolt12Request}; +use lightning::offers::offer::Offer; use tracing::instrument; use uuid::Uuid; @@ -65,10 +72,12 @@ impl Mint { amount } Some(MeltOptions::Amountless { amountless: _ }) => { - if !matches!( - settings.options, - Some(MeltMethodOptions::Bolt11 { amountless: true }) - ) { + if method == PaymentMethod::Bolt11 + && !matches!( + settings.options, + Some(MeltMethodOptions::Bolt11 { amountless: true }) + ) + { return Err(Error::AmountlessInvoiceNotSupported(unit, method)); } @@ -97,9 +106,28 @@ impl Mint { } } - /// Get melt bolt11 quote + /// Get melt quote for either BOLT11 or BOLT12 + /// + /// This function accepts a `MeltQuoteRequest` enum and delegates to the + /// appropriate handler based on the request type. #[instrument(skip_all)] - pub async fn get_melt_bolt11_quote( + pub async fn get_melt_quote( + &self, + melt_quote_request: MeltQuoteRequest, + ) -> Result, Error> { + match melt_quote_request { + MeltQuoteRequest::Bolt11(bolt11_request) => { + self.get_melt_bolt11_quote_impl(&bolt11_request).await + } + MeltQuoteRequest::Bolt12(bolt12_request) => { + self.get_melt_bolt12_quote_impl(&bolt12_request).await + } + } + } + + /// Implementation of get_melt_bolt11_quote + #[instrument(skip_all)] + async fn get_melt_bolt11_quote_impl( &self, melt_request: &MeltQuoteBolt11Request, ) -> Result, Error> { @@ -110,6 +138,19 @@ impl Mint { .. } = melt_request; + let amount_msats = melt_request.amount_msat()?; + + let amount_quote_unit = to_unit(amount_msats, &CurrencyUnit::Msat, unit)?; + + self.check_melt_request_acceptable( + amount_quote_unit, + unit.clone(), + PaymentMethod::Bolt11, + request.to_string(), + *options, + ) + .await?; + let ln = self .ln .get(&PaymentProcessorKey::new( @@ -122,11 +163,17 @@ impl Mint { Error::UnsupportedUnit })?; + let bolt11 = Bolt11OutgoingPaymentOptions { + bolt11: melt_request.request.clone(), + max_fee_amount: None, + timeout_secs: None, + melt_options: melt_request.options, + }; + let payment_quote = ln .get_payment_quote( - &melt_request.request.to_string(), &melt_request.unit, - melt_request.options, + OutgoingPaymentOptions::Bolt11(Box::new(bolt11)), ) .await .map_err(|err| { @@ -139,62 +186,137 @@ impl Mint { Error::UnsupportedUnit })?; - self.check_melt_request_acceptable( - payment_quote.amount, - unit.clone(), - PaymentMethod::Bolt11, - request.to_string(), - *options, - ) - .await?; - - // We only want to set the msats_to_pay of the melt quote if the invoice is amountless - // or we want to ignore the amount and do an mpp payment - let msats_to_pay = options.map(|opt| opt.amount_msat()); - let melt_ttl = self.localstore.get_quote_ttl().await?.melt_ttl; let quote = MeltQuote::new( - request.to_string(), + MeltPaymentRequest::Bolt11 { + bolt11: request.clone(), + }, unit.clone(), payment_quote.amount, payment_quote.fee, unix_time() + melt_ttl, payment_quote.request_lookup_id.clone(), - msats_to_pay, + *options, + PaymentMethod::Bolt11, ); tracing::debug!( "New melt quote {} for {} {} with request id {}", quote.id, - payment_quote.amount, + amount_quote_unit, unit, payment_quote.request_lookup_id ); let mut tx = self.localstore.begin_transaction().await?; - if let Some(mut from_db_quote) = tx.get_melt_quote("e.id).await? { - if from_db_quote.state != quote.state { - tx.update_melt_quote_state("e.id, from_db_quote.state) - .await?; - from_db_quote.state = quote.state; - } - if from_db_quote.request_lookup_id != quote.request_lookup_id { - tx.update_melt_quote_request_lookup_id("e.id, "e.request_lookup_id) - .await?; - from_db_quote.request_lookup_id = quote.request_lookup_id.clone(); - } - if from_db_quote != quote { - return Err(Error::Internal); - } - } else if let Err(err) = tx.add_melt_quote(quote.clone()).await { - match err { - database::Error::Duplicate => { - return Err(Error::RequestAlreadyPaid); + tx.add_melt_quote(quote.clone()).await?; + tx.commit().await?; + + Ok(quote.into()) + } + + /// Implementation of get_melt_bolt12_quote + #[instrument(skip_all)] + async fn get_melt_bolt12_quote_impl( + &self, + melt_request: &MeltQuoteBolt12Request, + ) -> Result, Error> { + let MeltQuoteBolt12Request { + request, + unit, + options, + } = melt_request; + + let offer = Offer::from_str(request).map_err(|_| Error::InvalidPaymentRequest)?; + + let amount = match options { + Some(options) => match options { + MeltOptions::Amountless { amountless } => { + to_unit(amountless.amount_msat, &CurrencyUnit::Msat, unit)? } - _ => return Err(Error::from(err)), - } - } + _ => return Err(Error::UnsupportedUnit), + }, + None => amount_for_offer(&offer, unit).map_err(|_| Error::UnsupportedUnit)?, + }; + + self.check_melt_request_acceptable( + amount, + unit.clone(), + PaymentMethod::Bolt12, + request.clone(), + *options, + ) + .await?; + + let ln = self + .ln + .get(&PaymentProcessorKey::new( + unit.clone(), + PaymentMethod::Bolt12, + )) + .ok_or_else(|| { + tracing::info!("Could not get ln backend for {}, bolt12 ", unit); + + Error::UnsupportedUnit + })?; + + let offer = Offer::from_str(&melt_request.request).map_err(|_| Error::Bolt12parse)?; + + let outgoing_payment_options = Bolt12OutgoingPaymentOptions { + offer: offer.clone(), + max_fee_amount: None, + timeout_secs: None, + melt_options: *options, + invoice: None, + }; + + let payment_quote = ln + .get_payment_quote( + &melt_request.unit, + OutgoingPaymentOptions::Bolt12(Box::new(outgoing_payment_options)), + ) + .await + .map_err(|err| { + tracing::error!( + "Could not get payment quote for mint quote, {} bolt12, {}", + unit, + err + ); + + Error::UnsupportedUnit + })?; + + let invoice = payment_quote.options.and_then(|options| match options { + PaymentQuoteOptions::Bolt12 { invoice } => invoice, + }); + + let payment_request = MeltPaymentRequest::Bolt12 { + offer: Box::new(offer), + invoice, + }; + + let quote = MeltQuote::new( + payment_request, + unit.clone(), + payment_quote.amount, + payment_quote.fee, + unix_time() + self.quote_ttl().await?.melt_ttl, + payment_quote.request_lookup_id.clone(), + *options, + PaymentMethod::Bolt12, + ); + + tracing::debug!( + "New melt quote {} for {} {} with request id {}", + quote.id, + amount, + unit, + payment_quote.request_lookup_id + ); + + let mut tx = self.localstore.begin_transaction().await?; + tx.add_melt_quote(quote.clone()).await?; tx.commit().await?; Ok(quote.into()) @@ -228,7 +350,7 @@ impl Mint { fee_reserve: quote.fee_reserve, payment_preimage: quote.payment_preimage, change, - request: Some(quote.request.clone()), + request: Some(quote.request.to_string()), unit: Some(quote.unit.clone()), }) } @@ -247,16 +369,40 @@ impl Mint { melt_quote: &MeltQuote, melt_request: &MeltRequest, ) -> Result, Error> { - let invoice = Bolt11Invoice::from_str(&melt_quote.request)?; - let quote_msats = to_unit(melt_quote.amount, &melt_quote.unit, &CurrencyUnit::Msat) .expect("Quote unit is checked above that it can convert to msat"); - let invoice_amount_msats: Amount = match invoice.amount_milli_satoshis() { - Some(amt) => amt.into(), - None => melt_quote - .msat_to_pay - .ok_or(Error::InvoiceAmountUndefined)?, + let invoice_amount_msats = match &melt_quote.request { + MeltPaymentRequest::Bolt11 { bolt11 } => match bolt11.amount_milli_satoshis() { + Some(amount) => amount.into(), + None => melt_quote + .options + .ok_or(Error::InvoiceAmountUndefined)? + .amount_msat(), + }, + MeltPaymentRequest::Bolt12 { offer, invoice: _ } => match offer.amount() { + Some(amount) => { + let (amount, currency) = match amount { + lightning::offers::offer::Amount::Bitcoin { amount_msats } => { + (amount_msats, CurrencyUnit::Msat) + } + lightning::offers::offer::Amount::Currency { + iso4217_code, + amount, + } => ( + amount, + CurrencyUnit::from_str(&String::from_utf8(iso4217_code.to_vec())?)?, + ), + }; + + to_unit(amount, ¤cy, &CurrencyUnit::Msat) + .map_err(|_err| Error::UnsupportedUnit)? + } + None => melt_quote + .options + .ok_or(Error::InvoiceAmountUndefined)? + .amount_msat(), + }, }; let partial_amount = match invoice_amount_msats > quote_msats { @@ -273,7 +419,7 @@ impl Mint { .map_err(|_| Error::UnsupportedUnit)?, }; - let inputs_amount_quote_unit = melt_request.proofs_amount().map_err(|_| { + let inputs_amount_quote_unit = melt_request.inputs_amount().map_err(|_| { tracing::error!("Proof inputs in melt quote overflowed"); Error::AmountOverflow })?; @@ -305,7 +451,7 @@ impl Mint { melt_request: &MeltRequest, ) -> Result<(ProofWriter, MeltQuote), Error> { let (state, quote) = tx - .update_melt_quote_state(melt_request.quote(), MeltQuoteState::Pending) + .update_melt_quote_state(melt_request.quote(), MeltQuoteState::Pending, None) .await?; match state { @@ -371,7 +517,7 @@ impl Mint { /// Melt Bolt11 #[instrument(skip_all)] - pub async fn melt_bolt11( + pub async fn melt( &self, melt_request: &MeltRequest, ) -> Result, Error> { @@ -455,7 +601,7 @@ impl Mint { tx.commit().await?; let pre = match ln - .make_payment(quote.clone(), partial_amount, Some(quote.fee_reserve)) + .make_payment("e.unit, quote.clone().try_into()?) .await { Ok(pay) @@ -602,8 +748,12 @@ impl Mint { .update_proofs_states(&mut tx, &input_ys, State::Spent) .await?; - tx.update_melt_quote_state(melt_request.quote(), MeltQuoteState::Paid) - .await?; + tx.update_melt_quote_state( + melt_request.quote(), + MeltQuoteState::Paid, + payment_preimage.clone(), + ) + .await?; self.pubsub_manager.melt_quote_status( "e, @@ -615,7 +765,7 @@ impl Mint { let mut change = None; // Check if there is change to return - if melt_request.proofs_amount()? > total_spent { + if melt_request.inputs_amount()? > total_spent { // Check if wallet provided change outputs if let Some(outputs) = melt_request.outputs().clone() { let blinded_messages: Vec = @@ -636,7 +786,7 @@ impl Mint { let fee = self.get_proofs_fee(melt_request.inputs()).await?; - let change_target = melt_request.proofs_amount()? - total_spent - fee; + let change_target = melt_request.inputs_amount()? - total_spent - fee; let mut amounts = change_target.split(); let mut change_sigs = Vec::with_capacity(amounts.len()); @@ -689,7 +839,7 @@ impl Mint { fee_reserve: quote.fee_reserve, state: MeltQuoteState::Paid, expiry: quote.expiry, - request: Some(quote.request.clone()), + request: Some(quote.request.to_string()), unit: Some(quote.unit.clone()), }) } diff --git a/crates/cdk/src/mint/mod.rs b/crates/cdk/src/mint/mod.rs index d9285a01..bd89527a 100644 --- a/crates/cdk/src/mint/mod.rs +++ b/crates/cdk/src/mint/mod.rs @@ -254,11 +254,32 @@ impl Mint { let mut join_set = JoinSet::new(); + let mut processor_groups: Vec<( + Arc + Send + Sync>, + Vec, + )> = Vec::new(); + for (key, ln) in self.ln.iter() { + // Check if we already have this processor + let found = processor_groups.iter_mut().find(|(proc_ref, _)| { + // Compare Arc pointer equality using ptr_eq + Arc::ptr_eq(proc_ref, ln) + }); + + if let Some((_, keys)) = found { + // We found this processor, add the key to its group + keys.push(key.clone()); + } else { + // New processor, create a new group + processor_groups.push((Arc::clone(ln), vec![key.clone()])); + } + } + + for (ln, key) in processor_groups { if !ln.is_wait_invoice_active() { tracing::info!("Wait payment for {:?} inactive starting.", key); let mint = Arc::clone(&mint_arc); - let ln = Arc::clone(ln); + let ln = Arc::clone(&ln); let shutdown = Arc::clone(&shutdown); let key = key.clone(); join_set.spawn(async move { @@ -274,7 +295,7 @@ impl Mint { match result { Ok(mut stream) => { while let Some(request_lookup_id) = stream.next().await { - if let Err(err) = mint.pay_mint_quote_for_request_id(&request_lookup_id).await { + if let Err(err) = mint.pay_mint_quote_for_request_id(request_lookup_id).await { tracing::warn!("{:?}", err); } } @@ -416,7 +437,10 @@ impl Mint { melt_quote: &MeltQuote, melt_request: &MeltRequest, ) -> Result, Error> { - let mint_quote = match tx.get_mint_quote_by_request(&melt_quote.request).await { + let mint_quote = match tx + .get_mint_quote_by_request(&melt_quote.request.to_string()) + .await + { Ok(Some(mint_quote)) => mint_quote, // Not an internal melt -> mint Ok(None) => return Ok(None), @@ -428,31 +452,32 @@ impl Mint { tracing::error!("internal stuff"); // Mint quote has already been settled, proofs should not be burned or held. - if mint_quote.state == MintQuoteState::Issued || mint_quote.state == MintQuoteState::Paid { + if mint_quote.state() == MintQuoteState::Issued + || mint_quote.state() == MintQuoteState::Paid + { return Err(Error::RequestAlreadyPaid); } - let inputs_amount_quote_unit = melt_request.proofs_amount().map_err(|_| { + let inputs_amount_quote_unit = melt_request.inputs_amount().map_err(|_| { tracing::error!("Proof inputs in melt quote overflowed"); Error::AmountOverflow })?; - let mut mint_quote = mint_quote; - - if mint_quote.amount > inputs_amount_quote_unit { - tracing::debug!( - "Not enough inuts provided: {} needed {}", - inputs_amount_quote_unit, - mint_quote.amount - ); - return Err(Error::InsufficientFunds); + if let Some(amount) = mint_quote.amount { + if amount > inputs_amount_quote_unit { + tracing::debug!( + "Not enough inuts provided: {} needed {}", + inputs_amount_quote_unit, + amount + ); + return Err(Error::InsufficientFunds); + } } - mint_quote.state = MintQuoteState::Paid; - let amount = melt_quote.amount; - tx.add_or_replace_mint_quote(mint_quote).await?; + tx.increment_mint_quote_amount_paid(&mint_quote.id, amount, melt_quote.id.to_string()) + .await?; Ok(Some(amount)) } diff --git a/crates/cdk/src/mint/start_up_check.rs b/crates/cdk/src/mint/start_up_check.rs index 95f09467..fa265a08 100644 --- a/crates/cdk/src/mint/start_up_check.rs +++ b/crates/cdk/src/mint/start_up_check.rs @@ -10,7 +10,7 @@ use crate::types::PaymentProcessorKey; impl Mint { /// Checks the states of melt quotes that are **PENDING** or **UNKNOWN** to the mint with the ln node pub async fn check_pending_melt_quotes(&self) -> Result<(), Error> { - let melt_quotes = self.localstore.get_melt_quotes().await?; + let melt_quotes = self.localstore.get_melt_quotes().await.unwrap(); let pending_quotes: Vec = melt_quotes .into_iter() .filter(|q| q.state == MeltQuoteState::Pending || q.state == MeltQuoteState::Unknown) @@ -53,7 +53,11 @@ impl Mint { }; if let Err(err) = tx - .update_melt_quote_state(&pending_quote.id, melt_quote_state) + .update_melt_quote_state( + &pending_quote.id, + melt_quote_state, + pay_invoice_response.payment_proof, + ) .await { tracing::error!( diff --git a/crates/cdk/src/mint/subscription/on_subscription.rs b/crates/cdk/src/mint/subscription/on_subscription.rs index 829cc6e1..efb3598e 100644 --- a/crates/cdk/src/mint/subscription/on_subscription.rs +++ b/crates/cdk/src/mint/subscription/on_subscription.rs @@ -48,6 +48,12 @@ impl OnNewSubscription for OnSubscription { Notification::MintQuoteBolt11(uuid) => { mint_queries.push(datastore.get_mint_quote(uuid)) } + Notification::MintQuoteBolt12(uuid) => { + mint_queries.push(datastore.get_mint_quote(uuid)) + } + Notification::MeltQuoteBolt12(uuid) => { + melt_queries.push(datastore.get_melt_quote(uuid)) + } } } diff --git a/crates/cdk/src/wallet/mint.rs b/crates/cdk/src/wallet/issue/issue_bolt11.rs similarity index 92% rename from crates/cdk/src/wallet/mint.rs rename to crates/cdk/src/wallet/issue/issue_bolt11.rs index 11def1e7..45cddfb9 100644 --- a/crates/cdk/src/wallet/mint.rs +++ b/crates/cdk/src/wallet/issue/issue_bolt11.rs @@ -1,10 +1,10 @@ use std::collections::HashMap; use cdk_common::nut04::MintMethodOptions; -use cdk_common::wallet::{Transaction, TransactionDirection}; +use cdk_common::wallet::{MintQuote, Transaction, TransactionDirection}; +use cdk_common::PaymentMethod; use tracing::instrument; -use super::MintQuote; use crate::amount::SplitTarget; use crate::dhke::construct_proofs; use crate::nuts::nut00::ProofsMethods; @@ -81,16 +81,16 @@ impl Wallet { let quote_res = self.client.post_mint_quote(request).await?; - let quote = MintQuote { + let quote = MintQuote::new( + quote_res.quote, mint_url, - id: quote_res.quote, - amount, + PaymentMethod::Bolt11, + Some(amount), unit, - request: quote_res.request, - state: quote_res.state, - expiry: quote_res.expiry.unwrap_or(0), - secret_key: Some(secret_key), - }; + quote_res.request, + quote_res.expiry.unwrap_or(0), + Some(secret_key), + ); self.localstore.add_mint_quote(quote.clone()).await?; @@ -196,6 +196,17 @@ impl Wallet { .await? .ok_or(Error::UnknownQuote)?; + if quote_info.payment_method != PaymentMethod::Bolt11 { + return Err(Error::UnsupportedPaymentMethod); + } + + let amount_mintable = quote_info.amount_mintable(); + + if amount_mintable == Amount::ZERO { + tracing::debug!("Amount mintable 0."); + return Err(Error::AmountUndefined); + } + let unix_time = unix_time(); if quote_info.expiry > unix_time { @@ -214,7 +225,7 @@ impl Wallet { let premint_secrets = match &spending_conditions { Some(spending_conditions) => PreMintSecrets::with_conditions( active_keyset_id, - quote_info.amount, + amount_mintable, &amount_split_target, spending_conditions, )?, @@ -222,7 +233,7 @@ impl Wallet { active_keyset_id, count, self.xpriv, - quote_info.amount, + amount_mintable, &amount_split_target, )?, }; diff --git a/crates/cdk/src/wallet/issue/issue_bolt12.rs b/crates/cdk/src/wallet/issue/issue_bolt12.rs new file mode 100644 index 00000000..9fb591a8 --- /dev/null +++ b/crates/cdk/src/wallet/issue/issue_bolt12.rs @@ -0,0 +1,258 @@ +use std::collections::HashMap; + +use cdk_common::nut04::MintMethodOptions; +use cdk_common::nut24::MintQuoteBolt12Request; +use cdk_common::wallet::{Transaction, TransactionDirection}; +use cdk_common::{Proofs, SecretKey}; +use tracing::instrument; + +use crate::amount::SplitTarget; +use crate::dhke::construct_proofs; +use crate::nuts::nut00::ProofsMethods; +use crate::nuts::{ + nut12, MintQuoteBolt12Response, MintRequest, PaymentMethod, PreMintSecrets, SpendingConditions, + State, +}; +use crate::types::ProofInfo; +use crate::util::unix_time; +use crate::wallet::MintQuote; +use crate::{Amount, Error, Wallet}; + +impl Wallet { + /// Mint Bolt12 + #[instrument(skip(self))] + pub async fn mint_bolt12_quote( + &self, + amount: Option, + description: Option, + ) -> Result { + let mint_url = self.mint_url.clone(); + let unit = &self.unit; + + // If we have a description, we check that the mint supports it. + if description.is_some() { + let mint_method_settings = self + .localstore + .get_mint(mint_url.clone()) + .await? + .ok_or(Error::IncorrectMint)? + .nuts + .nut04 + .get_settings(unit, &crate::nuts::PaymentMethod::Bolt12) + .ok_or(Error::UnsupportedUnit)?; + + match mint_method_settings.options { + Some(MintMethodOptions::Bolt11 { description }) if description => (), + _ => return Err(Error::InvoiceDescriptionUnsupported), + } + } + + let secret_key = SecretKey::generate(); + + let mint_request = MintQuoteBolt12Request { + amount, + unit: self.unit.clone(), + description, + pubkey: secret_key.public_key(), + }; + + let quote_res = self.client.post_mint_bolt12_quote(mint_request).await?; + + let quote = MintQuote::new( + quote_res.quote, + mint_url, + PaymentMethod::Bolt12, + amount, + unit.clone(), + quote_res.request, + quote_res.expiry.unwrap_or(0), + Some(secret_key), + ); + + self.localstore.add_mint_quote(quote.clone()).await?; + + Ok(quote) + } + + /// Mint bolt12 + #[instrument(skip(self))] + pub async fn mint_bolt12( + &self, + quote_id: &str, + amount: Option, + amount_split_target: SplitTarget, + spending_conditions: Option, + ) -> Result { + // Check that mint is in store of mints + if self + .localstore + .get_mint(self.mint_url.clone()) + .await? + .is_none() + { + self.get_mint_info().await?; + } + + let quote_info = self.localstore.get_mint_quote(quote_id).await?; + + let quote_info = if let Some(quote) = quote_info { + if quote.expiry.le(&unix_time()) && quote.expiry.ne(&0) { + return Err(Error::ExpiredQuote(quote.expiry, unix_time())); + } + + quote.clone() + } else { + return Err(Error::UnknownQuote); + }; + + let active_keyset_id = self.get_active_mint_keyset().await?.id; + + let count = self + .localstore + .get_keyset_counter(&active_keyset_id) + .await?; + + let count = count.map_or(0, |c| c + 1); + + let amount = match amount { + Some(amount) => amount, + None => { + // If an amount it not supplied with check the status of the quote + // The mint will tell us how much can be minted + let state = self.mint_bolt12_quote_state(quote_id).await?; + + state.amount_paid - state.amount_issued + } + }; + + if amount == Amount::ZERO { + tracing::error!("Cannot mint zero amount."); + return Err(Error::InvoiceAmountUndefined); + } + + let premint_secrets = match &spending_conditions { + Some(spending_conditions) => PreMintSecrets::with_conditions( + active_keyset_id, + amount, + &amount_split_target, + spending_conditions, + )?, + None => PreMintSecrets::from_xpriv( + active_keyset_id, + count, + self.xpriv, + amount, + &amount_split_target, + )?, + }; + + let mut request = MintRequest { + quote: quote_id.to_string(), + outputs: premint_secrets.blinded_messages(), + signature: None, + }; + + if let Some(secret_key) = quote_info.secret_key.clone() { + request.sign(secret_key)?; + } else { + tracing::error!("Signature is required for bolt12."); + return Err(Error::SignatureMissingOrInvalid); + } + + let mint_res = self.client.post_mint(request).await?; + + let keys = self.get_keyset_keys(active_keyset_id).await?; + + // Verify the signature DLEQ is valid + { + for (sig, premint) in mint_res.signatures.iter().zip(&premint_secrets.secrets) { + let keys = self.get_keyset_keys(sig.keyset_id).await?; + let key = keys.amount_key(sig.amount).ok_or(Error::AmountKey)?; + match sig.verify_dleq(key, premint.blinded_message.blinded_secret) { + Ok(_) | Err(nut12::Error::MissingDleqProof) => (), + Err(_) => return Err(Error::CouldNotVerifyDleq), + } + } + } + + let proofs = construct_proofs( + mint_res.signatures, + premint_secrets.rs(), + premint_secrets.secrets(), + &keys, + )?; + + // Remove filled quote from store + let mut quote_info = self + .localstore + .get_mint_quote(quote_id) + .await? + .ok_or(Error::UnpaidQuote)?; + quote_info.amount_issued += proofs.total_amount()?; + + self.localstore.add_mint_quote(quote_info.clone()).await?; + + if spending_conditions.is_none() { + // Update counter for keyset + self.localstore + .increment_keyset_counter(&active_keyset_id, proofs.len() as u32) + .await?; + } + + let proof_infos = proofs + .iter() + .map(|proof| { + ProofInfo::new( + proof.clone(), + self.mint_url.clone(), + State::Unspent, + quote_info.unit.clone(), + ) + }) + .collect::, _>>()?; + + // Add new proofs to store + self.localstore.update_proofs(proof_infos, vec![]).await?; + + // Add transaction to store + self.localstore + .add_transaction(Transaction { + mint_url: self.mint_url.clone(), + direction: TransactionDirection::Incoming, + amount: proofs.total_amount()?, + fee: Amount::ZERO, + unit: self.unit.clone(), + ys: proofs.ys()?, + timestamp: unix_time(), + memo: None, + metadata: HashMap::new(), + }) + .await?; + + Ok(proofs) + } + + /// Check mint quote status + #[instrument(skip(self, quote_id))] + pub async fn mint_bolt12_quote_state( + &self, + quote_id: &str, + ) -> Result, Error> { + let response = self.client.get_mint_quote_bolt12_status(quote_id).await?; + + match self.localstore.get_mint_quote(quote_id).await? { + Some(quote) => { + let mut quote = quote; + quote.amount_issued = response.amount_issued; + quote.amount_paid = response.amount_paid; + + self.localstore.add_mint_quote(quote).await?; + } + None => { + tracing::info!("Quote mint {} unknown", quote_id); + } + } + + Ok(response) + } +} diff --git a/crates/cdk/src/wallet/issue/mod.rs b/crates/cdk/src/wallet/issue/mod.rs new file mode 100644 index 00000000..8d74d484 --- /dev/null +++ b/crates/cdk/src/wallet/issue/mod.rs @@ -0,0 +1,2 @@ +mod issue_bolt11; +mod issue_bolt12; diff --git a/crates/cdk/src/wallet/melt.rs b/crates/cdk/src/wallet/melt/melt_bolt11.rs similarity index 99% rename from crates/cdk/src/wallet/melt.rs rename to crates/cdk/src/wallet/melt/melt_bolt11.rs index abccaae4..6dad683d 100644 --- a/crates/cdk/src/wallet/melt.rs +++ b/crates/cdk/src/wallet/melt/melt_bolt11.rs @@ -6,7 +6,6 @@ use cdk_common::wallet::{Transaction, TransactionDirection}; use lightning_invoice::Bolt11Invoice; use tracing::instrument; -use super::MeltQuote; use crate::amount::to_unit; use crate::dhke::construct_proofs; use crate::nuts::{ @@ -15,6 +14,7 @@ use crate::nuts::{ }; use crate::types::{Melted, ProofInfo}; use crate::util::unix_time; +use crate::wallet::MeltQuote; use crate::{ensure_cdk, Error, Wallet}; impl Wallet { diff --git a/crates/cdk/src/wallet/melt/melt_bolt12.rs b/crates/cdk/src/wallet/melt/melt_bolt12.rs new file mode 100644 index 00000000..93b48790 --- /dev/null +++ b/crates/cdk/src/wallet/melt/melt_bolt12.rs @@ -0,0 +1,89 @@ +//! Melt BOLT12 +//! +//! Implementation of melt functionality for BOLT12 offers + +use std::str::FromStr; + +use cdk_common::amount::amount_for_offer; +use cdk_common::wallet::MeltQuote; +use lightning::offers::offer::Offer; +use tracing::instrument; + +use crate::amount::to_unit; +use crate::nuts::{CurrencyUnit, MeltOptions, MeltQuoteBolt11Response, MeltQuoteBolt12Request}; +use crate::{Error, Wallet}; + +impl Wallet { + /// Melt Quote for BOLT12 offer + #[instrument(skip(self, request))] + pub async fn melt_bolt12_quote( + &self, + request: String, + options: Option, + ) -> Result { + let quote_request = MeltQuoteBolt12Request { + request: request.clone(), + unit: self.unit.clone(), + options, + }; + + let quote_res = self.client.post_melt_bolt12_quote(quote_request).await?; + + if self.unit == CurrencyUnit::Sat || self.unit == CurrencyUnit::Msat { + let offer = Offer::from_str(&request).map_err(|_| Error::Bolt12parse)?; + // Get amount from offer or options + let amount_msat = options + .map(|opt| opt.amount_msat()) + .or_else(|| amount_for_offer(&offer, &CurrencyUnit::Msat).ok()) + .ok_or(Error::AmountUndefined)?; + let amount_quote_unit = to_unit(amount_msat, &CurrencyUnit::Msat, &self.unit).unwrap(); + + if quote_res.amount != amount_quote_unit { + tracing::warn!( + "Mint returned incorrect quote amount. Expected {}, got {}", + amount_quote_unit, + quote_res.amount + ); + return Err(Error::IncorrectQuoteAmount); + } + } + + let quote = MeltQuote { + id: quote_res.quote, + amount: quote_res.amount, + request, + unit: self.unit.clone(), + fee_reserve: quote_res.fee_reserve, + state: quote_res.state, + expiry: quote_res.expiry, + payment_preimage: quote_res.payment_preimage, + }; + + self.localstore.add_melt_quote(quote.clone()).await?; + + Ok(quote) + } + + /// BOLT12 melt quote status + #[instrument(skip(self, quote_id))] + pub async fn melt_bolt12_quote_status( + &self, + quote_id: &str, + ) -> Result, Error> { + let response = self.client.get_melt_bolt12_quote_status(quote_id).await?; + + match self.localstore.get_melt_quote(quote_id).await? { + Some(quote) => { + let mut quote = quote; + + quote.state = response.state; + self.localstore.add_melt_quote(quote).await?; + } + None => { + tracing::info!("Quote melt {} unknown", quote_id); + } + } + + Ok(response) + } +} diff --git a/crates/cdk/src/wallet/melt/mod.rs b/crates/cdk/src/wallet/melt/mod.rs new file mode 100644 index 00000000..98f889a8 --- /dev/null +++ b/crates/cdk/src/wallet/melt/mod.rs @@ -0,0 +1,2 @@ +mod melt_bolt11; +mod melt_bolt12; diff --git a/crates/cdk/src/wallet/mint_connector/http_client.rs b/crates/cdk/src/wallet/mint_connector/http_client.rs index d15989bc..96720f5a 100644 --- a/crates/cdk/src/wallet/mint_connector/http_client.rs +++ b/crates/cdk/src/wallet/mint_connector/http_client.rs @@ -2,6 +2,7 @@ use std::sync::Arc; use async_trait::async_trait; +use cdk_common::{MeltQuoteBolt12Request, MintQuoteBolt12Request, MintQuoteBolt12Response}; #[cfg(feature = "auth")] use cdk_common::{Method, ProtectedEndpoint, RoutePath}; use reqwest::{Client, IntoUrl}; @@ -91,7 +92,9 @@ impl HttpClientCore { let response = request .send() .await - .map_err(|e| Error::HttpError(e.to_string()))? + .map_err(|e| Error::HttpError(e.to_string()))?; + + let response = response .text() .await .map_err(|e| Error::HttpError(e.to_string()))?; @@ -395,6 +398,103 @@ impl MintConnector for HttpClient { let auth_token = None; self.core.http_post(url, auth_token, &request).await } + + /// Mint Quote Bolt12 [NUT-23] + #[instrument(skip(self), fields(mint_url = %self.mint_url))] + async fn post_mint_bolt12_quote( + &self, + request: MintQuoteBolt12Request, + ) -> Result, Error> { + let url = self + .mint_url + .join_paths(&["v1", "mint", "quote", "bolt12"])?; + + #[cfg(feature = "auth")] + let auth_token = self + .get_auth_token(Method::Post, RoutePath::MintQuoteBolt12) + .await?; + + #[cfg(not(feature = "auth"))] + let auth_token = None; + + self.core.http_post(url, auth_token, &request).await + } + + /// Mint Quote Bolt12 status + #[instrument(skip(self), fields(mint_url = %self.mint_url))] + async fn get_mint_quote_bolt12_status( + &self, + quote_id: &str, + ) -> Result, Error> { + let url = self + .mint_url + .join_paths(&["v1", "mint", "quote", "bolt12", quote_id])?; + + #[cfg(feature = "auth")] + let auth_token = self + .get_auth_token(Method::Get, RoutePath::MintQuoteBolt12) + .await?; + + #[cfg(not(feature = "auth"))] + let auth_token = None; + self.core.http_get(url, auth_token).await + } + + /// Melt Quote Bolt12 [NUT-23] + #[instrument(skip(self, request), fields(mint_url = %self.mint_url))] + async fn post_melt_bolt12_quote( + &self, + request: MeltQuoteBolt12Request, + ) -> Result, Error> { + let url = self + .mint_url + .join_paths(&["v1", "melt", "quote", "bolt12"])?; + #[cfg(feature = "auth")] + let auth_token = self + .get_auth_token(Method::Post, RoutePath::MeltQuoteBolt12) + .await?; + + #[cfg(not(feature = "auth"))] + let auth_token = None; + self.core.http_post(url, auth_token, &request).await + } + + /// Melt Quote Bolt12 Status [NUT-23] + #[instrument(skip(self), fields(mint_url = %self.mint_url))] + async fn get_melt_bolt12_quote_status( + &self, + quote_id: &str, + ) -> Result, Error> { + let url = self + .mint_url + .join_paths(&["v1", "melt", "quote", "bolt12", quote_id])?; + + #[cfg(feature = "auth")] + let auth_token = self + .get_auth_token(Method::Get, RoutePath::MeltQuoteBolt12) + .await?; + + #[cfg(not(feature = "auth"))] + let auth_token = None; + self.core.http_get(url, auth_token).await + } + + /// Melt Bolt12 [NUT-23] + #[instrument(skip(self, request), fields(mint_url = %self.mint_url))] + async fn post_melt_bolt12( + &self, + request: MeltRequest, + ) -> Result, Error> { + let url = self.mint_url.join_paths(&["v1", "melt", "bolt12"])?; + #[cfg(feature = "auth")] + let auth_token = self + .get_auth_token(Method::Post, RoutePath::MeltBolt12) + .await?; + + #[cfg(not(feature = "auth"))] + let auth_token = None; + self.core.http_post(url, auth_token, &request).await + } } /// Http Client diff --git a/crates/cdk/src/wallet/mint_connector/mod.rs b/crates/cdk/src/wallet/mint_connector/mod.rs index 712ba8e9..eb88944a 100644 --- a/crates/cdk/src/wallet/mint_connector/mod.rs +++ b/crates/cdk/src/wallet/mint_connector/mod.rs @@ -3,6 +3,7 @@ use std::fmt::Debug; use async_trait::async_trait; +use cdk_common::{MeltQuoteBolt12Request, MintQuoteBolt12Request, MintQuoteBolt12Response}; use super::Error; use crate::nuts::{ @@ -77,4 +78,29 @@ pub trait MintConnector: Debug { /// Set auth wallet on client #[cfg(feature = "auth")] async fn set_auth_wallet(&self, wallet: Option); + /// Mint Quote [NUT-04] + async fn post_mint_bolt12_quote( + &self, + request: MintQuoteBolt12Request, + ) -> Result, Error>; + /// Mint Quote status + async fn get_mint_quote_bolt12_status( + &self, + quote_id: &str, + ) -> Result, Error>; + /// Melt Quote [NUT-23] + async fn post_melt_bolt12_quote( + &self, + request: MeltQuoteBolt12Request, + ) -> Result, Error>; + /// Melt Quote Status [NUT-23] + async fn get_melt_bolt12_quote_status( + &self, + quote_id: &str, + ) -> Result, Error>; + /// Melt [NUT-23] + async fn post_melt_bolt12( + &self, + request: MeltRequest, + ) -> Result, Error>; } diff --git a/crates/cdk/src/wallet/mod.rs b/crates/cdk/src/wallet/mod.rs index a0f499ee..1aa4db9a 100644 --- a/crates/cdk/src/wallet/mod.rs +++ b/crates/cdk/src/wallet/mod.rs @@ -34,9 +34,9 @@ use crate::OidcClient; mod auth; mod balance; mod builder; +mod issue; mod keysets; mod melt; -mod mint; mod mint_connector; pub mod multi_mint_wallet; mod proofs; diff --git a/misc/itests.sh b/misc/itests.sh index 6465ca78..dd2755c1 100755 --- a/misc/itests.sh +++ b/misc/itests.sh @@ -224,6 +224,13 @@ if [ $? -ne 0 ]; then exit 1 fi +echo "Running regtest test with cln mint for bolt12" +cargo test -p cdk-integration-tests --test bolt12 +if [ $? -ne 0 ]; then + echo "regtest test failed, exiting" + exit 1 +fi + # Switch Mints: Run tests with LND mint echo "Switching to LND mint for tests" export CDK_ITESTS_MINT_PORT_0=8087 diff --git a/misc/mintd_payment_processor.sh b/misc/mintd_payment_processor.sh index 376978da..d00dc740 100755 --- a/misc/mintd_payment_processor.sh +++ b/misc/mintd_payment_processor.sh @@ -50,6 +50,7 @@ cleanup() { unset CDK_MINTD_GRPC_PAYMENT_PROCESSOR_SUPPORTED_UNITS unset CDK_MINTD_MNEMONIC unset CDK_MINTD_PID + unset CDK_PAYMENT_PROCESSOR_CLN_BOLT12 } # Set up trap to call cleanup on script exit @@ -102,6 +103,7 @@ if [ "$LN_BACKEND" != "FAKEWALLET" ]; then sleep 1 done echo "Regtest set up continuing" + export CDK_PAYMENT_PROCESSOR_CLN_BOLT12=true fi # Start payment processor @@ -177,5 +179,17 @@ cargo test -p cdk-integration-tests --test happy_path_mint_wallet # Capture the exit status of cargo test test_status=$? +if [ "$LN_BACKEND" = "CLN" ]; then + echo "Running bolt12 tests for CLN backend" + cargo test -p cdk-integration-tests --test bolt12 + bolt12_test_status=$? + + # Exit with non-zero status if either test failed + if [ $test_status -ne 0 ] || [ $bolt12_test_status -ne 0 ]; then + echo "Tests failed - happy_path_mint_wallet: $test_status, bolt12: $bolt12_test_status" + exit 1 + fi +fi + # Exit with the status of the tests exit $test_status