From 9a3d9b7139efaea61e49eeb68e039787e7620128 Mon Sep 17 00:00:00 2001 From: thesimplekid Date: Thu, 21 Aug 2025 16:16:48 +0100 Subject: [PATCH] feat: bip353 (#969) --- crates/cdk-cli/Cargo.toml | 6 +- crates/cdk-cli/src/bip353.rs | 132 ---------- crates/cdk-cli/src/main.rs | 2 - crates/cdk-cli/src/sub_commands/melt.rs | 20 +- crates/cdk-common/src/error.rs | 10 + crates/cdk/Cargo.toml | 7 + crates/cdk/examples/bip353.rs | 143 +++++++++++ crates/cdk/src/bip353.rs | 286 ++++++++++++++++++++++ crates/cdk/src/lib.rs | 3 + crates/cdk/src/wallet/melt/melt_bip353.rs | 94 +++++++ crates/cdk/src/wallet/melt/mod.rs | 2 + 11 files changed, 555 insertions(+), 150 deletions(-) delete mode 100644 crates/cdk-cli/src/bip353.rs create mode 100644 crates/cdk/examples/bip353.rs create mode 100644 crates/cdk/src/bip353.rs create mode 100644 crates/cdk/src/wallet/melt/melt_bip353.rs diff --git a/crates/cdk-cli/Cargo.toml b/crates/cdk-cli/Cargo.toml index e6e83a28..a2c2e2dd 100644 --- a/crates/cdk-cli/Cargo.toml +++ b/crates/cdk-cli/Cargo.toml @@ -11,8 +11,7 @@ rust-version.workspace = true readme = "README.md" [features] -default = ["bip353"] -bip353 = ["dep:trust-dns-resolver"] +default = [] sqlcipher = ["cdk-sqlite/sqlcipher"] # MSRV is not tracked with redb enabled redb = ["dep:cdk-redb"] @@ -21,7 +20,7 @@ redb = ["dep:cdk-redb"] anyhow.workspace = true bip39.workspace = true bitcoin.workspace = true -cdk = { workspace = true, default-features = false, features = ["wallet", "auth"]} +cdk = { workspace = true, default-features = false, features = ["wallet", "auth", "bip353"]} cdk-redb = { workspace = true, features = ["wallet"], optional = true } cdk-sqlite = { workspace = true, features = ["wallet"] } clap.workspace = true @@ -40,4 +39,3 @@ 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 deleted file mode 100644 index 1d424be4..00000000 --- a/crates/cdk-cli/src/bip353.rs +++ /dev/null @@ -1,132 +0,0 @@ -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 9a601579..32a1c3ea 100644 --- a/crates/cdk-cli/src/main.rs +++ b/crates/cdk-cli/src/main.rs @@ -18,8 +18,6 @@ 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 ea2bd676..e03e1a11 100644 --- a/crates/cdk-cli/src/sub_commands/melt.rs +++ b/crates/cdk-cli/src/sub_commands/melt.rs @@ -1,6 +1,6 @@ use std::str::FromStr; -use anyhow::{anyhow, bail, Result}; +use anyhow::{bail, Result}; use cdk::amount::{amount_for_offer, Amount, MSAT_IN_SAT}; use cdk::mint_url::MintUrl; use cdk::nuts::{CurrencyUnit, MeltOptions}; @@ -12,7 +12,6 @@ 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, @@ -184,22 +183,19 @@ pub async fn pay( } PaymentType::Bip353 => { let bip353_addr = get_user_input("Enter Bip353 address.")?; - let bip353_addr = Bip353Address::from_str(&bip353_addr)?; - - let payment_instructions = bip353_addr.resolve().await?; - - let offer = payment_instructions - .parameters - .get(&Bip353PaymentType::LightningOffer) - .ok_or(anyhow!("Offer not defined"))?; 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?; + // Get melt quote for BIP353 address (internally resolves and gets BOLT12 quote) + let quote = wallet + .melt_bip353_quote( + &bip353_addr, + options.expect("Amount is required").amount_msat(), + ) + .await?; process_payment(&wallet, quote).await?; } } diff --git a/crates/cdk-common/src/error.rs b/crates/cdk-common/src/error.rs index 5d19ab4e..b1c7e27e 100644 --- a/crates/cdk-common/src/error.rs +++ b/crates/cdk-common/src/error.rs @@ -115,6 +115,16 @@ pub enum Error { #[error("Operation timeout")] Timeout, + /// BIP353 address parsing error + #[error("Failed to parse BIP353 address: {0}")] + Bip353Parse(String), + /// BIP353 address resolution error + #[error("Failed to resolve BIP353 address: {0}")] + Bip353Resolve(String), + /// BIP353 no Lightning offer found + #[error("No Lightning offer found in BIP353 payment instructions")] + Bip353NoLightningOffer, + /// Internal Error - Send error #[error("Internal send error: {0}")] SendError(String), diff --git a/crates/cdk/Cargo.toml b/crates/cdk/Cargo.toml index 8bcc70f1..02c5dbdb 100644 --- a/crates/cdk/Cargo.toml +++ b/crates/cdk/Cargo.toml @@ -15,6 +15,7 @@ default = ["mint", "wallet", "auth"] wallet = ["dep:futures", "dep:reqwest", "cdk-common/wallet", "dep:rustls"] mint = ["dep:futures", "dep:reqwest", "cdk-common/mint", "cdk-signatory"] auth = ["dep:jsonwebtoken", "cdk-common/auth", "cdk-common/auth"] +bip353 = ["dep:trust-dns-resolver"] # We do not commit to a MSRV with swagger enabled swagger = ["mint", "dep:utoipa", "cdk-common/swagger"] bench = [] @@ -42,6 +43,7 @@ url.workspace = true utoipa = { workspace = true, optional = true } uuid.workspace = true jsonwebtoken = { workspace = true, optional = true } +trust-dns-resolver = { version = "0.23.2", optional = true } # -Z minimal-versions sync_wrapper = "0.1.2" @@ -95,6 +97,10 @@ required-features = ["wallet"] name = "auth_wallet" required-features = ["wallet", "auth"] +[[example]] +name = "bip353" +required-features = ["wallet", "bip353"] + [dev-dependencies] rand.workspace = true cdk-sqlite.workspace = true @@ -102,6 +108,7 @@ bip39.workspace = true tracing-subscriber.workspace = true criterion = "0.6.0" reqwest = { workspace = true } +anyhow.workspace = true [[bench]] diff --git a/crates/cdk/examples/bip353.rs b/crates/cdk/examples/bip353.rs new file mode 100644 index 00000000..b617241d --- /dev/null +++ b/crates/cdk/examples/bip353.rs @@ -0,0 +1,143 @@ +//! # BIP-353 CDK Example +//! +//! This example demonstrates how to use BIP-353 (Human Readable Bitcoin Payment Instructions) +//! with the CDK wallet. BIP-353 allows users to share simple email-like addresses such as +//! `user@domain.com` instead of complex Bitcoin addresses or Lightning invoices. +//! +//! ## How it works +//! +//! 1. Parse a human-readable address like `alice@example.com` +//! 2. Query DNS TXT records at `alice.user._bitcoin-payment.example.com` +//! 3. Extract Bitcoin URIs from the TXT records +//! 4. Parse payment instructions (Lightning offers, on-chain addresses) +//! 5. Use CDK wallet to execute payments +//! +//! ## Usage +//! +//! ```bash +//! cargo run --example bip353 --features="wallet bip353" +//! ``` +//! +//! Note: The example uses a placeholder address that will fail DNS resolution. +//! To test with real addresses, you need a domain with proper BIP-353 DNS records. + +use std::sync::Arc; +use std::time::Duration; + +use cdk::amount::SplitTarget; +use cdk::nuts::nut00::ProofsMethods; +use cdk::nuts::{CurrencyUnit, MintQuoteState}; +use cdk::wallet::Wallet; +use cdk::Amount; +use cdk_sqlite::wallet::memory; +use rand::random; +use tokio::time::sleep; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + println!("BIP-353 CDK Example"); + println!("==================="); + + // Example BIP-353 address - replace with a real one that has BOLT12 offer + // For testing, you might need to set up your own DNS records + let bip353_address = "tsk@thesimplekid.com"; // This is just an example + + println!("Attempting to use BIP-353 address: {}", bip353_address); + + // Generate a random seed for the wallet + let seed = random::<[u8; 64]>(); + + // Mint URL and currency unit + let mint_url = "https://fake.thesimplekid.dev"; + let unit = CurrencyUnit::Sat; + let initial_amount = Amount::from(1000); // Start with 1000 sats + + // Initialize the memory store + let localstore = Arc::new(memory::empty().await?); + + // Create a new wallet + let wallet = Wallet::new(mint_url, unit, localstore, seed, None)?; + + // First, we need to fund the wallet + println!("Requesting mint quote for {} sats...", initial_amount); + let mint_quote = wallet.mint_quote(initial_amount, None).await?; + println!( + "Pay this invoice to fund the wallet: {}", + mint_quote.request + ); + + // In a real application, you would wait for the payment + // For this example, we'll just demonstrate the BIP353 melt process + println!("Waiting for payment... (in real use, pay the above invoice)"); + + // Check quote state (with timeout for demo purposes) + let timeout = Duration::from_secs(30); + let start = std::time::Instant::now(); + + while start.elapsed() < timeout { + let status = wallet.mint_quote_state(&mint_quote.id).await?; + + if status.state == MintQuoteState::Paid { + break; + } + + println!("Quote state: {} (waiting...)", status.state); + sleep(Duration::from_secs(2)).await; + } + + // Mint the tokens + let proofs = wallet + .mint(&mint_quote.id, SplitTarget::default(), None) + .await?; + let received_amount = proofs.total_amount()?; + println!("Successfully minted {} sats", received_amount); + + // Now prepare to pay using the BIP353 address + let payment_amount_sats = 100; // Example: paying 100 sats + + println!( + "Attempting to pay {} sats using BIP-353 address...", + payment_amount_sats + ); + + // Use the new wallet method to resolve BIP353 address and get melt quote + match wallet + .melt_bip353_quote(bip353_address, payment_amount_sats * 1_000) + .await + { + Ok(melt_quote) => { + println!("BIP-353 melt quote received:"); + println!(" Quote ID: {}", melt_quote.id); + println!(" Amount: {} sats", melt_quote.amount); + println!(" Fee Reserve: {} sats", melt_quote.fee_reserve); + println!(" State: {}", melt_quote.state); + + // Execute the payment + match wallet.melt(&melt_quote.id).await { + Ok(melt_result) => { + println!("BIP-353 payment successful!"); + println!(" State: {}", melt_result.state); + println!(" Amount paid: {} sats", melt_result.amount); + println!(" Fee paid: {} sats", melt_result.fee_paid); + + if let Some(preimage) = melt_result.preimage { + println!(" Payment preimage: {}", preimage); + } + } + Err(e) => { + println!("BIP-353 payment failed: {}", e); + } + } + } + Err(e) => { + println!("Failed to get BIP-353 melt quote: {}", e); + println!("This could be because:"); + println!("1. The BIP-353 address format is invalid"); + println!("2. DNS resolution failed (expected for this example)"); + println!("3. No Lightning offer found in the DNS records"); + println!("4. DNSSEC validation failed"); + } + } + + Ok(()) +} diff --git a/crates/cdk/src/bip353.rs b/crates/cdk/src/bip353.rs new file mode 100644 index 00000000..15b2c6c7 --- /dev/null +++ b/crates/cdk/src/bip353.rs @@ -0,0 +1,286 @@ +//! BIP-353: Human Readable Bitcoin Payment Instructions +//! +//! This module provides functionality for resolving human-readable Bitcoin addresses +//! according to BIP-353. It allows users to share simple email-like addresses such as +//! `user@domain.com` instead of complex Bitcoin addresses or Lightning invoices. + +use std::collections::HashMap; +use std::str::FromStr; + +use anyhow::{bail, Result}; +use trust_dns_resolver::config::{ResolverConfig, ResolverOpts}; +use trust_dns_resolver::TokioAsyncResolver; + +/// BIP-353 human-readable Bitcoin address +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Bip353Address { + /// The user part of the address (before @) + pub user: String, + /// The domain part of the address (after @) + pub domain: String, +} + +impl Bip353Address { + /// Resolve a human-readable Bitcoin address to payment instructions + /// + /// This method performs the following steps: + /// 1. Constructs the DNS name according to BIP-353 format + /// 2. Queries TXT records with DNSSEC validation + /// 3. Extracts Bitcoin URIs from the records + /// 4. Parses the URIs into payment instructions + /// + /// # Errors + /// + /// This method will return an error if: + /// - DNS resolution fails + /// - DNSSEC validation fails + /// - No Bitcoin URI is found + /// - Multiple Bitcoin URIs are found (BIP-353 requires exactly one) + /// - The URI format is invalid + pub(crate) async fn resolve(self) -> Result { + // Construct DNS name according to BIP-353 + 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 from string format + /// + /// Accepts formats: + /// - `user@domain.com` + /// - `₿user@domain.com` (with Bitcoin symbol prefix) + /// + /// # Errors + /// + /// Returns an error if: + /// - The format is not `user@domain` + /// - User or domain parts are empty + 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(), + }) + } +} + +impl std::fmt::Display for Bip353Address { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}@{}", self.user, self.domain) + } +} + +/// Payment instruction type +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum PaymentType { + /// On-chain Bitcoin address + OnChain, + /// Lightning Offer (BOLT12) + LightningOffer, +} + +/// BIP-353 payment instruction containing parsed payment methods +#[derive(Debug, Clone)] +pub struct PaymentInstruction { + /// Map of payment types to their corresponding values + pub parameters: HashMap, +} + +impl PaymentInstruction { + /// Create a new empty payment instruction + pub fn new() -> Self { + Self { + parameters: HashMap::new(), + } + } + + /// Parse a payment instruction from a Bitcoin URI + /// + /// Extracts various payment methods from the URI: + /// - Lightning offers (parameters containing "lno") + /// - On-chain addresses (address part of the URI) + /// + /// # Errors + /// + /// Returns an error if the URI doesn't start with "bitcoin:" + 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(); + + // Determine payment type based on parameter key + if key.contains("lno") { + parameters.insert(PaymentType::LightningOffer, value); + } + // Could add more payment types here as needed + } + } + } + + // Check if we have an on-chain address (address part after bitcoin:) + if let Some(query_start) = uri.find('?') { + let addr_part = &uri[8..query_start]; // Skip "bitcoin:" + if !addr_part.is_empty() { + parameters.insert(PaymentType::OnChain, addr_part.to_string()); + } + } else { + // No query parameters, check if there's just an address + let addr_part = &uri[8..]; // Skip "bitcoin:" + if !addr_part.is_empty() { + parameters.insert(PaymentType::OnChain, addr_part.to_string()); + } + } + + Ok(PaymentInstruction { parameters }) + } + + /// Get a payment method by type + pub fn get(&self, payment_type: &PaymentType) -> Option<&String> { + self.parameters.get(payment_type) + } +} + +impl Default for PaymentInstruction { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + impl PaymentInstruction { + /// Check if a payment type is available + pub fn has_payment_type(&self, payment_type: &PaymentType) -> bool { + self.parameters.contains_key(payment_type) + } + } + + #[test] + fn test_bip353_address_parsing() { + // Test basic parsing + let addr = Bip353Address::from_str("alice@example.com").unwrap(); + assert_eq!(addr.user, "alice"); + assert_eq!(addr.domain, "example.com"); + + // Test with Bitcoin symbol + let addr = Bip353Address::from_str("₿bob@bitcoin.org").unwrap(); + assert_eq!(addr.user, "bob"); + assert_eq!(addr.domain, "bitcoin.org"); + + // Test with whitespace + let addr = Bip353Address::from_str(" charlie@test.net ").unwrap(); + assert_eq!(addr.user, "charlie"); + assert_eq!(addr.domain, "test.net"); + + // Test display + let addr = Bip353Address { + user: "test".to_string(), + domain: "example.com".to_string(), + }; + assert_eq!(addr.to_string(), "test@example.com"); + } + + #[test] + fn test_bip353_address_parsing_errors() { + // Test invalid formats + assert!(Bip353Address::from_str("invalid").is_err()); + assert!(Bip353Address::from_str("@example.com").is_err()); + assert!(Bip353Address::from_str("user@").is_err()); + assert!(Bip353Address::from_str("user@domain@extra").is_err()); + assert!(Bip353Address::from_str("").is_err()); + } + + #[test] + fn test_payment_instruction_parsing() { + // Test Lightning offer URI + let uri = "bitcoin:?lno=lno1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0y2pxqrjszs5v2a5m5xwc4mxv6rdjdcn2d3kxccnjdgecf7fz3rf5g4t7gdxhkzm8mpsq5q"; + let instruction = PaymentInstruction::from_uri(uri).unwrap(); + assert!(instruction.has_payment_type(&PaymentType::LightningOffer)); + + // Test on-chain address URI + let uri = "bitcoin:bc1qexampleaddress"; + let instruction = PaymentInstruction::from_uri(uri).unwrap(); + assert!(instruction.has_payment_type(&PaymentType::OnChain)); + assert_eq!( + instruction.get(&PaymentType::OnChain).unwrap(), + "bc1qexampleaddress" + ); + + // Test combined URI + let uri = "bitcoin:bc1qexampleaddress?lno=lno1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0y2pxqrjszs5v2a5m5xwc4mxv6rdjdcn2d3kxccnjdgecf7fz3rf5g4t7gdxhkzm8mpsq5q"; + let instruction = PaymentInstruction::from_uri(uri).unwrap(); + assert!(instruction.has_payment_type(&PaymentType::OnChain)); + assert!(instruction.has_payment_type(&PaymentType::LightningOffer)); + } + + #[test] + fn test_payment_instruction_errors() { + // Test invalid URI + assert!(PaymentInstruction::from_uri("invalid:uri").is_err()); + assert!(PaymentInstruction::from_uri("").is_err()); + } +} diff --git a/crates/cdk/src/lib.rs b/crates/cdk/src/lib.rs index 4cc809a7..aea22fcc 100644 --- a/crates/cdk/src/lib.rs +++ b/crates/cdk/src/lib.rs @@ -22,6 +22,9 @@ pub mod mint; #[cfg(feature = "wallet")] pub mod wallet; +#[cfg(feature = "bip353")] +mod bip353; + #[cfg(all(any(feature = "wallet", feature = "mint"), feature = "auth"))] mod oidc_client; diff --git a/crates/cdk/src/wallet/melt/melt_bip353.rs b/crates/cdk/src/wallet/melt/melt_bip353.rs new file mode 100644 index 00000000..b1b5c401 --- /dev/null +++ b/crates/cdk/src/wallet/melt/melt_bip353.rs @@ -0,0 +1,94 @@ +//! Melt BIP353 +//! +//! Implementation of melt functionality for BIP353 human-readable addresses + +use std::str::FromStr; + +use cdk_common::wallet::MeltQuote; +use tracing::instrument; + +#[cfg(feature = "bip353")] +use crate::bip353::{Bip353Address, PaymentType}; +use crate::nuts::MeltOptions; +use crate::{Amount, Error, Wallet}; + +impl Wallet { + /// Melt Quote for BIP353 human-readable address + /// + /// This method resolves a BIP353 address (e.g., "alice@example.com") to a Lightning offer + /// and then creates a melt quote for that offer. + /// + /// # Arguments + /// + /// * `bip353_address` - Human-readable address in the format "user@domain.com" + /// * `amount_msat` - Amount to pay in millisatoshis + /// + /// # Returns + /// + /// A `MeltQuote` that can be used to execute the payment + /// + /// # Errors + /// + /// This method will return an error if: + /// - The BIP353 address format is invalid + /// - DNS resolution fails or DNSSEC validation fails + /// - No Lightning offer is found in the payment instructions + /// - The mint fails to provide a quote for the offer + /// + /// # Example + /// + /// ```rust,no_run + /// use cdk::Amount; + /// # use cdk::Wallet; + /// # async fn example(wallet: Wallet) -> Result<(), cdk::Error> { + /// let quote = wallet + /// .melt_bip353_quote("alice@example.com", Amount::from(100_000)) // 100 sats in msat + /// .await?; + /// # Ok(()) + /// # } + /// ``` + #[cfg(feature = "bip353")] + #[instrument(skip(self, amount_msat), fields(address = %bip353_address))] + pub async fn melt_bip353_quote( + &self, + bip353_address: &str, + amount_msat: impl Into, + ) -> Result { + // Parse the BIP353 address + let address = Bip353Address::from_str(bip353_address).map_err(|e| { + tracing::error!("Failed to parse BIP353 address '{}': {}", bip353_address, e); + Error::Bip353Parse(e.to_string()) + })?; + + tracing::debug!("Resolving BIP353 address: {}", address); + + // Keep a copy for error reporting + let address_string = address.to_string(); + + // Resolve the address to get payment instructions + let payment_instructions = address.resolve().await.map_err(|e| { + tracing::error!( + "Failed to resolve BIP353 address '{}': {}", + address_string, + e + ); + Error::Bip353Resolve(e.to_string()) + })?; + + // Extract the Lightning offer from the payment instructions + let offer = payment_instructions + .get(&PaymentType::LightningOffer) + .ok_or_else(|| { + tracing::error!("No Lightning offer found in BIP353 payment instructions"); + Error::Bip353NoLightningOffer + })?; + + tracing::debug!("Found Lightning offer in BIP353 instructions: {}", offer); + + // Create melt options with the provided amount + let options = MeltOptions::new_amountless(amount_msat); + + // Create a melt quote for the BOLT12 offer + self.melt_bolt12_quote(offer.clone(), Some(options)).await + } +} diff --git a/crates/cdk/src/wallet/melt/mod.rs b/crates/cdk/src/wallet/melt/mod.rs index 3dc4262a..09ab55ce 100644 --- a/crates/cdk/src/wallet/melt/mod.rs +++ b/crates/cdk/src/wallet/melt/mod.rs @@ -7,6 +7,8 @@ use tracing::instrument; use crate::Wallet; +#[cfg(feature = "bip353")] +mod melt_bip353; mod melt_bolt11; mod melt_bolt12;