Melt to amountless invoice (#497)

* feat: melt token with amountless

* fix: docs

* fix: extra migration
This commit is contained in:
thesimplekid
2025-04-04 13:16:27 +01:00
committed by GitHub
parent 09f339e6c6
commit d224cc57b5
16 changed files with 161 additions and 28 deletions

View File

@@ -58,6 +58,11 @@ pub enum MeltOptions {
/// MPP /// MPP
mpp: Mpp, mpp: Mpp,
}, },
/// Amountless options
Amountless {
/// Amountless
amountless: Amountless,
},
} }
impl MeltOptions { 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 /// Payment amount
pub fn amount_msat(&self) -> Amount { pub fn amount_msat(&self) -> Amount {
match self { match self {
Self::Mpp { mpp } => mpp.amount, 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 { impl MeltQuoteBolt11Request {
/// Amount from [`MeltQuoteBolt11Request`] /// Amount from [`MeltQuoteBolt11Request`]
/// ///
@@ -100,6 +126,15 @@ impl MeltQuoteBolt11Request {
.ok_or(Error::InvalidAmountRequest)? .ok_or(Error::InvalidAmountRequest)?
.into()), .into()),
Some(MeltOptions::Mpp { mpp }) => Ok(mpp.amount), 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 /// Max Amount
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub max_amount: Option<Amount>, pub max_amount: Option<Amount>,
/// Amountless
#[serde(default)]
pub amountless: bool,
} }
impl Settings { impl Settings {

View File

@@ -57,39 +57,52 @@ pub async fn pay(
stdin.read_line(&mut user_input)?; stdin.read_line(&mut user_input)?;
let bolt11 = Bolt11Invoice::from_str(user_input.trim())?; 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 mut user_input = String::new();
let stdin = io::stdin(); io::stdout().flush()?;
io::stdout().flush().unwrap(); io::stdin().read_line(&mut user_input)?;
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 if user_amount > available_funds {
.gt(&(<cdk::Amount as Into<u64>>::into(mints_amounts[mint_number].1) * MSAT_IN_SAT))
{
bail!("Not enough funds"); bail!("Not enough funds");
} }
options = Some(MeltOptions::new_mpp(user_amount * MSAT_IN_SAT)); Some(if sub_command_args.mpp {
} else if bolt11 MeltOptions::new_mpp(user_amount)
.amount_milli_satoshis() } else {
.unwrap() MeltOptions::new_amountless(user_amount)
.gt(&(<cdk::Amount as Into<u64>>::into(mints_amounts[mint_number].1) * MSAT_IN_SAT)) })
{ } else {
bail!("Not enough funds"); // 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?; let quote = wallet.melt_quote(bolt11.to_string(), options).await?;
println!("{:?}", quote); println!("{:?}", quote);
let melt = wallet.melt(&quote.id).await?; let melt = wallet.melt(&quote.id).await?;
println!("Paid invoice: {}", melt.state); println!("Paid invoice: {}", melt.state);
if let Some(preimage) = melt.preimage { if let Some(preimage) = melt.preimage {
println!("Payment preimage: {}", preimage); println!("Payment preimage: {}", preimage);
} }

View File

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

View File

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

View File

@@ -88,6 +88,9 @@ pub enum Error {
/// Could not get mint info /// Could not get mint info
#[error("Could not get mint info")] #[error("Could not get mint info")]
CouldNotGetMintInfo, 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 // Mint Errors
/// Minting is disabled /// Minting is disabled

View File

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

View File

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

View File

@@ -4,11 +4,11 @@ use std::time::Duration;
use anyhow::{bail, Result}; use anyhow::{bail, Result};
use bip39::Mnemonic; use bip39::Mnemonic;
use cashu::{MeltOptions, Mpp}; use cashu::ProofsMethods;
use cdk::amount::{Amount, SplitTarget}; use cdk::amount::{Amount, SplitTarget};
use cdk::nuts::{ use cdk::nuts::{
CurrencyUnit, MeltQuoteState, MintBolt11Request, MintQuoteState, NotificationPayload, CurrencyUnit, MeltOptions, MeltQuoteState, MintBolt11Request, MintQuoteState, Mpp,
PreMintSecrets, NotificationPayload, PreMintSecrets,
}; };
use cdk::wallet::{HttpClient, MintConnector, Wallet, WalletSubscription}; use cdk::wallet::{HttpClient, MintConnector, Wallet, WalletSubscription};
use cdk_integration_tests::init_regtest::{ use cdk_integration_tests::init_regtest::{
@@ -189,17 +189,21 @@ async fn test_websocket_connection() -> Result<()> {
async fn test_multimint_melt() -> Result<()> { async fn test_multimint_melt() -> Result<()> {
let lnd_client = init_lnd_client().await; let lnd_client = init_lnd_client().await;
let db = Arc::new(memory::empty().await?);
let wallet1 = Wallet::new( let wallet1 = Wallet::new(
&get_mint_url_from_env(), &get_mint_url_from_env(),
CurrencyUnit::Sat, CurrencyUnit::Sat,
Arc::new(memory::empty().await?), db,
&Mnemonic::generate(12)?.to_seed_normalized(""), &Mnemonic::generate(12)?.to_seed_normalized(""),
None, None,
)?; )?;
let db = Arc::new(memory::empty().await?);
db.migrate().await;
let wallet2 = Wallet::new( let wallet2 = Wallet::new(
&get_second_mint_url_from_env(), &get_second_mint_url_from_env(),
CurrencyUnit::Sat, CurrencyUnit::Sat,
Arc::new(memory::empty().await?), db,
&Mnemonic::generate(12)?.to_seed_normalized(""), &Mnemonic::generate(12)?.to_seed_normalized(""),
None, None,
)?; )?;
@@ -293,3 +297,44 @@ async fn test_cached_mint() -> Result<()> {
assert!(response == response1); assert!(response == response1);
Ok(()) 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(())
}

View File

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

View File

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

View File

@@ -527,6 +527,10 @@ impl CdkMint for MintRPCServer {
.max .max
.map(Amount::from) .map(Amount::from)
.or_else(|| current_nut05_settings.as_ref().and_then(|s| s.max_amount)), .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); methods.push(updated_method_settings);

View File

@@ -97,6 +97,9 @@ impl From<cdk_common::nut05::MeltOptions> for Options {
cdk_common::MeltOptions::Mpp { mpp } => Self::Mpp(Mpp { cdk_common::MeltOptions::Mpp { mpp } => Self::Mpp(Mpp {
amount: mpp.amount.into(), 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"); let options = value.options.expect("option defined");
match options { match options {
Options::Mpp(mpp) => cdk_common::MeltOptions::new_mpp(mpp.amount), Options::Mpp(mpp) => cdk_common::MeltOptions::new_mpp(mpp.amount),
Options::Amountless(amountless) => {
cdk_common::MeltOptions::new_amountless(amountless.amount_msat)
}
} }
} }
} }

View File

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

View File

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

View File

@@ -61,6 +61,16 @@ impl Mint {
// because should have already been converted to the partial amount // because should have already been converted to the partial amount
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, None => amount,
}; };

View File

@@ -62,7 +62,7 @@ impl Wallet {
options, 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 { if quote_res.amount != amount_quote_unit {
tracing::warn!( tracing::warn!(