thesimplekid il y a 2 mois
Parent
commit
9a3d9b7139

+ 2 - 4
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 }

+ 0 - 132
crates/cdk-cli/src/bip353.rs

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

+ 0 - 2
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;

+ 8 - 12
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?;
             }
         }

+ 10 - 0
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),

+ 7 - 0
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]]

+ 143 - 0
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(())
+}

+ 286 - 0
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<PaymentInstruction> {
+        // 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<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 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<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(),
+        })
+    }
+}
+
+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<PaymentType, String>,
+}
+
+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<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();
+
+                    // 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());
+    }
+}

+ 3 - 0
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;
 

+ 94 - 0
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<Amount>,
+    ) -> Result<MeltQuote, Error> {
+        // 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
+    }
+}

+ 2 - 0
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;