feat: bolt12

This commit is contained in:
thesimplekid
2025-07-06 21:20:25 +01:00
parent 34e91dc924
commit ae6c107809
86 changed files with 6297 additions and 1934 deletions

View File

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

View File

@@ -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<PaymentInstruction> {
// 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<String> = 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<Self, Self::Err> {
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<PaymentType, String>,
}
impl PaymentInstruction {
/// Parse a payment instruction from a Bitcoin URI
pub fn from_uri(uri: &str) -> Result<Self> {
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 })
}
}

View File

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

View File

@@ -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<String>,
/// 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(&quote.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<u64>,
prompt: &str,
) -> Result<Option<MeltOptions>> {
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::<u64>(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(&quote.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 = <cdk::Amount as Into<u64>>::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::<u64>(&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(&quote.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<String>,
) -> Result<(Vec<usize>, Vec<u64>)> {
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<Vec<(Wallet, MeltQuote)>> {
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(&quote.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(())
}

View File

@@ -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<String>,
/// Payment method
#[arg(long, default_value = "bolt11")]
method: String,
/// Expiry
#[arg(short, long)]
expiry: Option<u64>,
/// Expiry
#[arg(short, long)]
single_use: Option<bool>,
}
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(&quote_id, SplitTarget::default(), None).await?;
tracing::debug!("Attempting mint for: {}", payment_method);
let proofs = match payment_method {
PaymentMethod::Bolt11 => wallet.mint(&quote_id, SplitTarget::default(), None).await?,
PaymentMethod::Bolt12 => {
let response = wallet.mint_bolt12_quote_state(&quote_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(
&quote_id,
Some(amount_mintable),
SplitTarget::default(),
None,
)
.await?
}
_ => {
todo!()
}
};
let receive_amount = proofs.total_amount()?;