Răsfoiți Sursa

Melt to amountless invoice (#497)

* feat: melt token with amountless

* fix: docs

* fix: extra migration
thesimplekid 1 lună în urmă
părinte
comite
d224cc57b5

+ 38 - 0
crates/cashu/src/nuts/nut05.rs

@@ -58,6 +58,11 @@ pub enum MeltOptions {
         /// MPP
         mpp: Mpp,
     },
+    /// Amountless options
+    Amountless {
+        /// Amountless
+        amountless: Amountless,
+    },
 }
 
 impl MeltOptions {
@@ -73,14 +78,35 @@ impl MeltOptions {
         }
     }
 
+    /// Create new [`MeltOptions::Amountless`]
+    pub fn new_amountless<A>(amount_msat: A) -> Self
+    where
+        A: Into<Amount>,
+    {
+        Self::Amountless {
+            amountless: Amountless {
+                amount_msat: amount_msat.into(),
+            },
+        }
+    }
+
     /// Payment amount
     pub fn amount_msat(&self) -> Amount {
         match self {
             Self::Mpp { mpp } => mpp.amount,
+            Self::Amountless { amountless } => amountless.amount_msat,
         }
     }
 }
 
+/// Amountless payment
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
+#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
+pub struct Amountless {
+    /// Amount to pay in msat
+    pub amount_msat: Amount,
+}
+
 impl MeltQuoteBolt11Request {
     /// Amount from [`MeltQuoteBolt11Request`]
     ///
@@ -100,6 +126,15 @@ impl MeltQuoteBolt11Request {
                 .ok_or(Error::InvalidAmountRequest)?
                 .into()),
             Some(MeltOptions::Mpp { mpp }) => Ok(mpp.amount),
+            Some(MeltOptions::Amountless { amountless }) => {
+                let amount = amountless.amount_msat;
+                if let Some(amount_msat) = request.amount_milli_satoshis() {
+                    if amount != amount_msat.into() {
+                        return Err(Error::InvalidAmountRequest);
+                    }
+                }
+                Ok(amount)
+            }
         }
     }
 }
@@ -392,6 +427,9 @@ pub struct MeltMethodSettings {
     /// Max Amount
     #[serde(skip_serializing_if = "Option::is_none")]
     pub max_amount: Option<Amount>,
+    /// Amountless
+    #[serde(default)]
+    pub amountless: bool,
 }
 
 impl Settings {

+ 33 - 20
crates/cdk-cli/src/sub_commands/melt.rs

@@ -57,39 +57,52 @@ pub async fn pay(
     stdin.read_line(&mut user_input)?;
     let bolt11 = Bolt11Invoice::from_str(user_input.trim())?;
 
-    let mut options: Option<MeltOptions> = None;
+    let available_funds =
+        <cdk::Amount as Into<u64>>::into(mints_amounts[mint_number].1) * MSAT_IN_SAT;
+
+    // Determine payment amount and options
+    let options = if sub_command_args.mpp || bolt11.amount_milli_satoshis().is_none() {
+        // Get user input for amount
+        println!(
+            "Enter the amount you would like to pay in sats for a {} payment.",
+            if sub_command_args.mpp {
+                "MPP"
+            } else {
+                "amountless invoice"
+            }
+        );
 
-    if sub_command_args.mpp {
-        println!("Enter the amount you would like to pay in sats, for a mpp payment.");
         let mut user_input = String::new();
-        let stdin = io::stdin();
-        io::stdout().flush().unwrap();
-        stdin.read_line(&mut user_input)?;
+        io::stdout().flush()?;
+        io::stdin().read_line(&mut user_input)?;
 
-        let user_amount = user_input.trim_end().parse::<u64>()?;
+        let user_amount = user_input.trim_end().parse::<u64>()? * MSAT_IN_SAT;
 
-        if user_amount
-            .gt(&(<cdk::Amount as Into<u64>>::into(mints_amounts[mint_number].1) * MSAT_IN_SAT))
-        {
+        if user_amount > available_funds {
             bail!("Not enough funds");
         }
 
-        options = Some(MeltOptions::new_mpp(user_amount * MSAT_IN_SAT));
-    } else if bolt11
-        .amount_milli_satoshis()
-        .unwrap()
-        .gt(&(<cdk::Amount as Into<u64>>::into(mints_amounts[mint_number].1) * MSAT_IN_SAT))
-    {
-        bail!("Not enough funds");
-    }
+        Some(if sub_command_args.mpp {
+            MeltOptions::new_mpp(user_amount)
+        } else {
+            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");
+        }
+        None
+    };
 
+    // Process payment
     let quote = wallet.melt_quote(bolt11.to_string(), options).await?;
-
     println!("{:?}", quote);
 
     let melt = wallet.melt(&quote.id).await?;
-
     println!("Paid invoice: {}", melt.state);
+
     if let Some(preimage) = melt.preimage {
         println!("Payment preimage: {}", preimage);
     }

+ 1 - 0
crates/cdk-cln/src/lib.rs

@@ -72,6 +72,7 @@ impl MintPayment for Cln {
             mpp: true,
             unit: CurrencyUnit::Msat,
             invoice_description: true,
+            amountless: true,
         })?)
     }
 

+ 2 - 1
crates/cdk-common/src/common.rs

@@ -43,7 +43,8 @@ impl Melted {
 
         let fee_paid = proofs_amount
             .checked_sub(amount + change_amount)
-            .ok_or(Error::AmountOverflow)?;
+            .ok_or(Error::AmountOverflow)
+            .unwrap();
 
         Ok(Self {
             state,

+ 3 - 0
crates/cdk-common/src/error.rs

@@ -88,6 +88,9 @@ pub enum Error {
     /// Could not get mint info
     #[error("Could not get mint info")]
     CouldNotGetMintInfo,
+    /// Multi-Part Payment not supported for unit and method
+    #[error("Amountless invoices are not supported for unit `{0}` and method `{1}`")]
+    AmountlessInvoiceNotSupported(CurrencyUnit, PaymentMethod),
 
     // Mint Errors
     /// Minting is disabled

+ 2 - 0
crates/cdk-common/src/payment.rs

@@ -165,6 +165,8 @@ pub struct Bolt11Settings {
     pub unit: CurrencyUnit,
     /// Invoice Description supported
     pub invoice_description: bool,
+    /// Paying amountless invoices supported
+    pub amountless: bool,
 }
 
 impl TryFrom<Bolt11Settings> for Value {

+ 1 - 0
crates/cdk-fake-wallet/src/lib.rs

@@ -109,6 +109,7 @@ impl MintPayment for FakeWallet {
             mpp: true,
             unit: CurrencyUnit::Msat,
             invoice_description: true,
+            amountless: false,
         })?)
     }
 

+ 50 - 5
crates/cdk-integration-tests/tests/regtest.rs

@@ -4,11 +4,11 @@ use std::time::Duration;
 
 use anyhow::{bail, Result};
 use bip39::Mnemonic;
-use cashu::{MeltOptions, Mpp};
+use cashu::ProofsMethods;
 use cdk::amount::{Amount, SplitTarget};
 use cdk::nuts::{
-    CurrencyUnit, MeltQuoteState, MintBolt11Request, MintQuoteState, NotificationPayload,
-    PreMintSecrets,
+    CurrencyUnit, MeltOptions, MeltQuoteState, MintBolt11Request, MintQuoteState, Mpp,
+    NotificationPayload, PreMintSecrets,
 };
 use cdk::wallet::{HttpClient, MintConnector, Wallet, WalletSubscription};
 use cdk_integration_tests::init_regtest::{
@@ -189,17 +189,21 @@ async fn test_websocket_connection() -> Result<()> {
 async fn test_multimint_melt() -> Result<()> {
     let lnd_client = init_lnd_client().await;
 
+    let db = Arc::new(memory::empty().await?);
     let wallet1 = Wallet::new(
         &get_mint_url_from_env(),
         CurrencyUnit::Sat,
-        Arc::new(memory::empty().await?),
+        db,
         &Mnemonic::generate(12)?.to_seed_normalized(""),
         None,
     )?;
+
+    let db = Arc::new(memory::empty().await?);
+    db.migrate().await;
     let wallet2 = Wallet::new(
         &get_second_mint_url_from_env(),
         CurrencyUnit::Sat,
-        Arc::new(memory::empty().await?),
+        db,
         &Mnemonic::generate(12)?.to_seed_normalized(""),
         None,
     )?;
@@ -293,3 +297,44 @@ async fn test_cached_mint() -> Result<()> {
     assert!(response == response1);
     Ok(())
 }
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_regtest_melt_amountless() -> Result<()> {
+    let lnd_client = init_lnd_client().await;
+
+    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_amount = Amount::from(100);
+
+    let mint_quote = wallet.mint_quote(mint_amount, None).await?;
+
+    assert_eq!(mint_quote.amount, mint_amount);
+
+    lnd_client.pay_invoice(mint_quote.request).await?;
+
+    let proofs = wallet
+        .mint(&mint_quote.id, SplitTarget::default(), None)
+        .await?;
+
+    let amount = proofs.total_amount()?;
+
+    assert!(mint_amount == amount);
+
+    let invoice = lnd_client.create_invoice(None).await?;
+
+    let options = MeltOptions::new_amountless(5_000);
+
+    let melt_quote = wallet.melt_quote(invoice.clone(), Some(options)).await?;
+
+    let melt = wallet.melt(&melt_quote.id).await.unwrap();
+
+    assert!(melt.amount == 5.into());
+
+    Ok(())
+}

+ 1 - 0
crates/cdk-lnbits/src/lib.rs

@@ -69,6 +69,7 @@ impl LNbits {
                 mpp: false,
                 unit: CurrencyUnit::Sat,
                 invoice_description: true,
+                amountless: false,
             },
         })
     }

+ 1 - 0
crates/cdk-lnd/src/lib.rs

@@ -104,6 +104,7 @@ impl Lnd {
                 mpp: true,
                 unit: CurrencyUnit::Msat,
                 invoice_description: true,
+                amountless: true,
             },
         })
     }

+ 4 - 0
crates/cdk-mint-rpc/src/proto/server.rs

@@ -527,6 +527,10 @@ impl CdkMint for MintRPCServer {
                 .max
                 .map(Amount::from)
                 .or_else(|| current_nut05_settings.as_ref().and_then(|s| s.max_amount)),
+            amountless: current_nut05_settings
+                .as_ref()
+                .map(|s| s.amountless)
+                .unwrap_or_default(),
         };
 
         methods.push(updated_method_settings);

+ 6 - 0
crates/cdk-payment-processor/src/proto/mod.rs

@@ -97,6 +97,9 @@ impl From<cdk_common::nut05::MeltOptions> for Options {
             cdk_common::MeltOptions::Mpp { mpp } => Self::Mpp(Mpp {
                 amount: mpp.amount.into(),
             }),
+            cdk_common::MeltOptions::Amountless { amountless } => Self::Amountless(Amountless {
+                amount_msat: amountless.amount_msat.into(),
+            }),
         }
     }
 }
@@ -106,6 +109,9 @@ impl From<MeltOptions> for cdk_common::nut05::MeltOptions {
         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)
+            }
         }
     }
 }

+ 6 - 0
crates/cdk-payment-processor/src/proto/payment_processor.proto

@@ -35,9 +35,15 @@ message Mpp {
     uint64 amount = 1;
 }
 
+
+message Amountless {
+    uint64 amount_msat = 1;
+}
+
 message MeltOptions {
     oneof options {
         Mpp mpp = 1;
+        Amountless amountless = 2;
     }
 }
 

+ 2 - 1
crates/cdk/src/mint/builder.rs

@@ -202,10 +202,11 @@ impl MintBuilder {
             self.mint_info.nuts.nut04.disabled = false;
 
             let melt_method_settings = MeltMethodSettings {
-                method: method.clone(),
+                method,
                 unit,
                 min_amount: Some(limits.melt_min),
                 max_amount: Some(limits.melt_max),
+                amountless: settings.amountless,
             };
             self.mint_info.nuts.nut05.methods.push(melt_method_settings);
             self.mint_info.nuts.nut05.disabled = false;

+ 10 - 0
crates/cdk/src/mint/melt.rs

@@ -61,6 +61,16 @@ impl Mint {
                 // because should have already been converted to the partial amount
                 amount
             }
+            Some(MeltOptions::Amountless { amountless: _ }) => {
+                if !nut15
+                    .methods
+                    .into_iter()
+                    .any(|m| m.method == method && m.unit == unit)
+                {
+                    return Err(Error::AmountlessInvoiceNotSupported(unit, method));
+                }
+                amount
+            }
             None => amount,
         };
 

+ 1 - 1
crates/cdk/src/wallet/melt.rs

@@ -62,7 +62,7 @@ impl Wallet {
             options,
         };
 
-        let quote_res = self.client.post_melt_quote(quote_request).await?;
+        let quote_res = self.client.post_melt_quote(quote_request).await.unwrap();
 
         if quote_res.amount != amount_quote_unit {
             tracing::warn!(