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,
},
/// 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 {

View File

@@ -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);
}

View File

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

View File

@@ -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,

View File

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

View File

@@ -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 {

View File

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

View File

@@ -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(())
}

View File

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

View File

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

View File

@@ -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);

View File

@@ -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)
}
}
}
}

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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,
};

View File

@@ -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!(