浏览代码

feat(cdk): add Lightning address support with BIP353 fallback (#1295)

* feat(cdk): add Lightning address support with BIP353 fallback

Implements Lightning address (user@domain.com) resolution for melt operations with automatic fallback mechanism. When BIP353 DNS resolution fails, the wallet now falls back to LNURL-pay Lightning address resolution.

Key additions:
- New lightning_address module with LNURL-pay protocol implementation
- melt_lightning_address and melt_human_readable_address methods
- MintConnector trait methods for LNURL HTTP requests
- BIP353 error variant for DNS resolution failures
- Integration tests and FFI bindings
tsk 1 天之前
父节点
当前提交
141e62a8dc

+ 7 - 0
crates/cdk-common/src/error.rs

@@ -129,6 +129,13 @@ pub enum Error {
     #[error("No Lightning offer found in BIP353 payment instructions")]
     Bip353NoLightningOffer,
 
+    /// Lightning Address parsing error
+    #[error("Failed to parse Lightning address: {0}")]
+    LightningAddressParse(String),
+    /// Lightning Address request error
+    #[error("Failed to request invoice from Lightning address service: {0}")]
+    LightningAddressRequest(String),
+
     /// Internal Error - Send error
     #[error("Internal send error: {0}")]
     SendError(String),

+ 39 - 0
crates/cdk-ffi/src/wallet.rs

@@ -423,6 +423,45 @@ impl Wallet {
             .await?;
         Ok(quote.into())
     }
+
+    /// Get a quote for a Lightning address melt
+    ///
+    /// This method resolves a Lightning address (e.g., "alice@example.com") to a Lightning invoice
+    /// and then creates a melt quote for that invoice.
+    pub async fn melt_lightning_address_quote(
+        &self,
+        lightning_address: String,
+        amount_msat: Amount,
+    ) -> Result<MeltQuote, FfiError> {
+        let cdk_amount: cdk::Amount = amount_msat.into();
+        let quote = self
+            .inner
+            .melt_lightning_address_quote(&lightning_address, cdk_amount)
+            .await?;
+        Ok(quote.into())
+    }
+
+    /// Get a quote for a human-readable address melt
+    ///
+    /// This method accepts a human-readable address that could be either a BIP353 address
+    /// or a Lightning address. It intelligently determines which to try based on mint support:
+    ///
+    /// 1. If the mint supports Bolt12, it tries BIP353 first
+    /// 2. Falls back to Lightning address only if BIP353 DNS resolution fails
+    /// 3. If BIP353 resolves but fails at the mint, it does NOT fall back to Lightning address
+    /// 4. If the mint doesn't support Bolt12, it tries Lightning address directly
+    pub async fn melt_human_readable(
+        &self,
+        address: String,
+        amount_msat: Amount,
+    ) -> Result<MeltQuote, FfiError> {
+        let cdk_amount: cdk::Amount = amount_msat.into();
+        let quote = self
+            .inner
+            .melt_human_readable_quote(&address, cdk_amount)
+            .await?;
+        Ok(quote.into())
+    }
 }
 
 /// Auth methods for Wallet

+ 14 - 0
crates/cdk-integration-tests/src/init_pure_tests.rs

@@ -59,6 +59,20 @@ impl MintConnector for DirectMintConnection {
         panic!("Not implemented");
     }
 
+    async fn fetch_lnurl_pay_request(
+        &self,
+        _url: &str,
+    ) -> Result<cdk::wallet::LnurlPayResponse, Error> {
+        unimplemented!("Lightning address not supported in DirectMintConnection")
+    }
+
+    async fn fetch_lnurl_invoice(
+        &self,
+        _url: &str,
+    ) -> Result<cdk::wallet::LnurlPayInvoiceResponse, Error> {
+        unimplemented!("Lightning address not supported in DirectMintConnection")
+    }
+
     async fn get_mint_keys(&self) -> Result<Vec<KeySet>, Error> {
         Ok(self.mint.pubkeys().keysets)
     }

+ 4 - 0
crates/cdk/Cargo.toml

@@ -138,6 +138,10 @@ required-features = ["wallet"]
 name = "mint-token-bolt12"
 required-features = ["wallet"]
 
+[[example]]
+name = "human_readable_payment"
+required-features = ["wallet", "bip353"]
+
 [dev-dependencies]
 rand.workspace = true
 cdk-sqlite.workspace = true

+ 300 - 0
crates/cdk/examples/human_readable_payment.rs

@@ -0,0 +1,300 @@
+//! # Human Readable Payment Example
+//!
+//! This example demonstrates how to use both BIP-353 and Lightning Address (LNURL-pay)
+//! with the CDK wallet. Both allow users to share simple email-like addresses instead
+//! of complex Bitcoin addresses or Lightning invoices.
+//!
+//! ## BIP-353 (Bitcoin URI Payment Instructions)
+//!
+//! BIP-353 uses DNS TXT records to resolve human-readable addresses to BOLT12 offers.
+//! 1. Parse a human-readable address like `user@domain.com`
+//! 2. Query DNS TXT records at `user.user._bitcoin-payment.domain.com`
+//! 3. Extract Lightning offers (BOLT12) from the TXT records
+//! 4. Use the offer to create a melt quote
+//!
+//! ## Lightning Address (LNURL-pay)
+//!
+//! Lightning Address uses HTTPS to fetch BOLT11 invoices.
+//! 1. Parse a Lightning address like `user@domain.com`
+//! 2. Query HTTPS endpoint at `https://domain.com/.well-known/lnurlp/user`
+//! 3. Get callback URL and amount constraints
+//! 4. Request BOLT11 invoice with the specified amount
+//!
+//! ## Unified API
+//!
+//! The `melt_human_readable_quote()` method automatically tries BIP-353 first
+//! (if the mint supports BOLT12), then falls back to Lightning Address if needed.
+//!
+//! ## Usage
+//!
+//! ```bash
+//! cargo run --example human_readable_payment --features="wallet bip353"
+//! ```
+
+use std::sync::Arc;
+use std::time::Duration;
+
+use cdk::amount::SplitTarget;
+use cdk::nuts::nut00::ProofsMethods;
+use cdk::nuts::CurrencyUnit;
+use cdk::wallet::Wallet;
+use cdk::Amount;
+use cdk_sqlite::wallet::memory;
+use rand::random;
+
+#[tokio::main]
+async fn main() -> anyhow::Result<()> {
+    println!("Human Readable Payment Example");
+    println!("================================\n");
+
+    // Example addresses
+    let bip353_address = "tsk@thesimplekid.com";
+    let lnurl_address =
+        "npub1qjgcmlpkeyl8mdkvp4s0xls4ytcux6my606tgfx9xttut907h0zs76lgjw@npubx.cash";
+
+    // 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(2000); // Start with 2000 sats (enough for both payments)
+
+    // 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)?;
+
+    println!("Step 1: Funding the wallet");
+    println!("---------------------------");
+
+    // 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:\n{}",
+        mint_quote.request
+    );
+    println!("\nQuote ID: {}", mint_quote.id);
+
+    // Wait for payment and mint tokens automatically
+    println!("\nWaiting for payment... (in real use, pay the above invoice)");
+    let proofs = wallet
+        .wait_and_mint_quote(
+            mint_quote,
+            SplitTarget::default(),
+            None,
+            Duration::from_secs(300), // 5 minutes timeout
+        )
+        .await?;
+
+    let received_amount = proofs.total_amount()?;
+    println!("✓ Successfully minted {} sats\n", received_amount);
+
+    // ============================================================================
+    // Part 1: BIP-353 Payment
+    // ============================================================================
+
+    println!("\n╔════════════════════════════════════════════════════════════════╗");
+    println!("║ Part 1: BIP-353 Payment (BOLT12 Offer via DNS)                ║");
+    println!("╚════════════════════════════════════════════════════════════════╝\n");
+
+    let bip353_amount_sats = 100; // Example: paying 100 sats
+    println!("BIP-353 Address: {}", bip353_address);
+    println!("Payment Amount: {} sats", bip353_amount_sats);
+    println!("\nHow BIP-353 works:");
+    println!("1. Parse address into user@domain");
+    println!("2. Query DNS TXT records at: tsk.user._bitcoin-payment.thesimplekid.com");
+    println!("3. Extract BOLT12 offer from DNS records");
+    println!("4. Create melt quote with the offer\n");
+
+    // Use the specific BIP353 method
+    println!("Attempting BIP-353 payment...");
+    match wallet
+        .melt_bip353_quote(bip353_address, bip353_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);
+            println!("  Payment Method: {}", melt_quote.payment_method);
+
+            // Execute the payment
+            println!("\nExecuting 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!("\nPossible reasons:");
+            println!("  • DNS resolution failed or no DNS records found");
+            println!("  • No Lightning offer (BOLT12) in DNS TXT records");
+            println!("  • DNSSEC validation failed");
+            println!("  • Mint doesn't support BOLT12");
+            println!("  • Network connectivity issues");
+        }
+    }
+
+    // ============================================================================
+    // Part 2: Lightning Address (LNURL-pay) Payment
+    // ============================================================================
+
+    println!("\n\n╔════════════════════════════════════════════════════════════════╗");
+    println!("║ Part 2: Lightning Address Payment (BOLT11 via LNURL-pay)      ║");
+    println!("╚════════════════════════════════════════════════════════════════╝\n");
+
+    let lnurl_amount_sats = 100; // Example: paying 100 sats
+    println!("Lightning Address: {}", lnurl_address);
+    println!("Payment Amount: {} sats", lnurl_amount_sats);
+    println!("\nHow Lightning Address works:");
+    println!("1. Parse address into user@domain");
+    println!("2. Query HTTPS: https://npubx.cash/.well-known/lnurlp/npub1qj...");
+    println!("3. Get callback URL and amount constraints");
+    println!("4. Request BOLT11 invoice for the amount");
+    println!("5. Create melt quote with the invoice\n");
+
+    // Use the specific Lightning Address method
+    println!("Attempting Lightning Address payment...");
+    match wallet
+        .melt_lightning_address_quote(lnurl_address, lnurl_amount_sats * 1_000)
+        .await
+    {
+        Ok(melt_quote) => {
+            println!("✓ Lightning Address 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);
+            println!("  Payment Method: {}", melt_quote.payment_method);
+
+            // Execute the payment
+            println!("\nExecuting payment...");
+            match wallet.melt(&melt_quote.id).await {
+                Ok(melt_result) => {
+                    println!("✓ Lightning Address 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!("✗ Lightning Address payment failed: {}", e);
+                }
+            }
+        }
+        Err(e) => {
+            println!("✗ Failed to get Lightning Address melt quote: {}", e);
+            println!("\nPossible reasons:");
+            println!("  • HTTPS request to .well-known/lnurlp failed");
+            println!("  • Invalid Lightning Address format");
+            println!("  • Amount outside min/max constraints");
+            println!("  • Service unavailable or network issues");
+        }
+    }
+
+    // ============================================================================
+    // Part 3: Unified Human Readable API (Smart Fallback)
+    // ============================================================================
+
+    println!("\n\n╔════════════════════════════════════════════════════════════════╗");
+    println!("║ Part 3: Unified API (Automatic BIP-353 → LNURL Fallback)      ║");
+    println!("╚════════════════════════════════════════════════════════════════╝\n");
+
+    println!("The `melt_human_readable_quote()` method intelligently chooses:");
+    println!("1. If mint supports BOLT12 AND address has BIP-353 DNS: Use BIP-353");
+    println!("2. If BIP-353 DNS fails OR address has no DNS: Fall back to LNURL");
+    println!("3. If mint doesn't support BOLT12: Use LNURL directly\n");
+
+    // Test 1: Address with BIP-353 support (has DNS records)
+    let unified_amount_sats = 50;
+    println!("Test 1: Address with BIP-353 DNS support");
+    println!("Address: {}", bip353_address);
+    println!("Payment Amount: {} sats", unified_amount_sats);
+    println!("Expected: BIP-353 (BOLT12) via DNS resolution\n");
+
+    println!("Attempting unified payment...");
+    match wallet
+        .melt_human_readable_quote(bip353_address, unified_amount_sats * 1_000)
+        .await
+    {
+        Ok(melt_quote) => {
+            println!("✓ Unified melt quote received:");
+            println!("  Quote ID: {}", melt_quote.id);
+            println!("  Amount: {} sats", melt_quote.amount);
+            println!("  Fee Reserve: {} sats", melt_quote.fee_reserve);
+            println!("  Payment Method: {}", melt_quote.payment_method);
+
+            let method_str = melt_quote.payment_method.to_string().to_lowercase();
+            let used_method = if method_str.contains("bolt12") {
+                "BIP-353 (BOLT12)"
+            } else if method_str.contains("bolt11") {
+                "Lightning Address (LNURL-pay)"
+            } else {
+                "Unknown method"
+            };
+            println!("\n  → Used: {}", used_method);
+        }
+        Err(e) => {
+            println!("✗ Failed to get unified melt quote: {}", e);
+            println!("  Both BIP-353 and Lightning Address resolution failed");
+        }
+    }
+
+    // Test 2: Address without BIP-353 support (LNURL only)
+    println!("\n\nTest 2: Address without BIP-353 (LNURL-only)");
+    println!("Address: {}", lnurl_address);
+    println!("Payment Amount: {} sats", unified_amount_sats);
+    println!("Expected: Lightning Address (LNURL-pay) fallback\n");
+
+    println!("Attempting unified payment...");
+    match wallet
+        .melt_human_readable_quote(lnurl_address, unified_amount_sats * 1_000)
+        .await
+    {
+        Ok(melt_quote) => {
+            println!("✓ Unified melt quote received:");
+            println!("  Quote ID: {}", melt_quote.id);
+            println!("  Amount: {} sats", melt_quote.amount);
+            println!("  Fee Reserve: {} sats", melt_quote.fee_reserve);
+            println!("  Payment Method: {}", melt_quote.payment_method);
+
+            let method_str = melt_quote.payment_method.to_string().to_lowercase();
+            let used_method = if method_str.contains("bolt12") {
+                "BIP-353 (BOLT12)"
+            } else if method_str.contains("bolt11") {
+                "Lightning Address (LNURL-pay)"
+            } else {
+                "Unknown method"
+            };
+            println!("\n  → Used: {}", used_method);
+            println!("\n  Note: This address doesn't have BIP-353 DNS records,");
+            println!("        so it automatically fell back to LNURL-pay.");
+        }
+        Err(e) => {
+            println!("✗ Failed to get unified melt quote: {}", e);
+            println!("  Both BIP-353 and Lightning Address resolution failed");
+        }
+    }
+
+    Ok(())
+}

+ 3 - 0
crates/cdk/src/lib.rs

@@ -32,6 +32,9 @@ mod test_helpers;
 #[cfg(all(feature = "bip353", not(target_arch = "wasm32")))]
 mod bip353;
 
+#[cfg(feature = "wallet")]
+mod lightning_address;
+
 #[cfg(all(any(feature = "wallet", feature = "mint"), feature = "auth"))]
 mod oidc_client;
 

+ 238 - 0
crates/cdk/src/lightning_address.rs

@@ -0,0 +1,238 @@
+//! Lightning Address Implementation
+//!
+//! This module provides functionality for resolving Lightning addresses
+//! to obtain Lightning invoices. Lightning addresses are user-friendly
+//! identifiers that look like email addresses (e.g., user@domain.com).
+//!
+//! Lightning addresses are converted to LNURL-pay endpoints following the spec:
+//! <https://domain.com/.well-known/lnurlp/user>
+
+use std::str::FromStr;
+use std::sync::Arc;
+
+use lightning_invoice::Bolt11Invoice;
+use serde::{Deserialize, Serialize};
+use thiserror::Error;
+use tracing::instrument;
+use url::Url;
+
+use crate::wallet::MintConnector;
+use crate::Amount;
+
+/// Lightning Address Error
+#[derive(Debug, Error)]
+pub enum Error {
+    /// Invalid Lightning address format
+    #[error("Invalid Lightning address format: {0}")]
+    InvalidFormat(String),
+    /// Invalid URL
+    #[error("Invalid URL: {0}")]
+    InvalidUrl(#[from] url::ParseError),
+    /// Failed to fetch pay request data
+    #[error("Failed to fetch pay request data: {0}")]
+    FetchPayRequest(#[from] crate::Error),
+    /// Lightning address service error
+    #[error("Lightning address service error: {0}")]
+    Service(String),
+    /// Amount below minimum
+    #[error("Amount {amount} msat is below minimum {min} msat")]
+    AmountBelowMinimum { amount: u64, min: u64 },
+    /// Amount above maximum
+    #[error("Amount {amount} msat is above maximum {max} msat")]
+    AmountAboveMaximum { amount: u64, max: u64 },
+    /// No invoice in response
+    #[error("No invoice in response")]
+    NoInvoice,
+    /// Failed to parse invoice
+    #[error("Failed to parse invoice: {0}")]
+    InvoiceParse(String),
+}
+
+/// Lightning address - represents a user@domain.com address
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(crate) struct LightningAddress {
+    /// The user part of the address (before @)
+    user: String,
+    /// The domain part of the address (after @)
+    domain: String,
+}
+
+impl LightningAddress {
+    /// Convert the Lightning address to an HTTPS URL for the LNURL-pay endpoint
+    fn to_url(&self) -> Result<Url, Error> {
+        // Lightning address spec: https://domain.com/.well-known/lnurlp/user
+        let url_str = format!("https://{}/.well-known/lnurlp/{}", self.domain, self.user);
+        Ok(Url::parse(&url_str)?)
+    }
+
+    /// Fetch the LNURL-pay metadata from the service
+    #[instrument(skip(client))]
+    async fn fetch_pay_request_data(
+        &self,
+        client: &Arc<dyn MintConnector + Send + Sync>,
+    ) -> Result<LnurlPayResponse, Error> {
+        let url = self.to_url()?;
+
+        tracing::debug!("Fetching Lightning address pay data from: {}", url);
+
+        // Make HTTP GET request to fetch the pay request data
+        let lnurl_response = client.fetch_lnurl_pay_request(url.as_str()).await?;
+
+        // Validate the response
+        if let Some(ref reason) = lnurl_response.reason {
+            return Err(Error::Service(reason.clone()));
+        }
+
+        Ok(lnurl_response)
+    }
+
+    /// Request an invoice from the Lightning address service with a specific amount
+    #[instrument(skip(client))]
+    pub(crate) async fn request_invoice(
+        &self,
+        client: &Arc<dyn MintConnector + Send + Sync>,
+        amount_msat: Amount,
+    ) -> Result<Bolt11Invoice, Error> {
+        let pay_data = self.fetch_pay_request_data(client).await?;
+
+        // Validate amount is within acceptable range
+        let amount_msat_u64: u64 = amount_msat.into();
+        if amount_msat_u64 < pay_data.min_sendable {
+            return Err(Error::AmountBelowMinimum {
+                amount: amount_msat_u64,
+                min: pay_data.min_sendable,
+            });
+        }
+        if amount_msat_u64 > pay_data.max_sendable {
+            return Err(Error::AmountAboveMaximum {
+                amount: amount_msat_u64,
+                max: pay_data.max_sendable,
+            });
+        }
+
+        // Build callback URL with amount parameter
+        let mut callback_url = Url::parse(&pay_data.callback)?;
+
+        callback_url
+            .query_pairs_mut()
+            .append_pair("amount", &amount_msat_u64.to_string());
+
+        tracing::debug!("Requesting invoice from callback: {}", callback_url);
+
+        // Fetch the invoice
+        let invoice_response = client.fetch_lnurl_invoice(callback_url.as_str()).await?;
+
+        // Check for errors
+        if let Some(ref reason) = invoice_response.reason {
+            return Err(Error::Service(reason.clone()));
+        }
+
+        // Parse and return the invoice
+        let pr = invoice_response.pr.ok_or(Error::NoInvoice)?;
+
+        Bolt11Invoice::from_str(&pr).map_err(|e| Error::InvoiceParse(e.to_string()))
+    }
+}
+
+impl FromStr for LightningAddress {
+    type Err = Error;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        let trimmed = s.trim();
+
+        // Parse Lightning address (user@domain)
+        if !trimmed.contains('@') {
+            return Err(Error::InvalidFormat("must contain '@'".to_string()));
+        }
+
+        let parts: Vec<&str> = trimmed.split('@').collect();
+        if parts.len() != 2 {
+            return Err(Error::InvalidFormat("must be user@domain".to_string()));
+        }
+
+        let user = parts[0].trim();
+        let domain = parts[1].trim();
+
+        if user.is_empty() || domain.is_empty() {
+            return Err(Error::InvalidFormat(
+                "user and domain must not be empty".to_string(),
+            ));
+        }
+
+        Ok(LightningAddress {
+            user: user.to_string(),
+            domain: domain.to_string(),
+        })
+    }
+}
+
+impl std::fmt::Display for LightningAddress {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(f, "{}@{}", self.user, self.domain)
+    }
+}
+
+/// LNURL-pay response from the initial request
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct LnurlPayResponse {
+    /// Callback URL for requesting invoice
+    pub callback: String,
+    /// Minimum amount in millisatoshis
+    #[serde(rename = "minSendable")]
+    pub min_sendable: u64,
+    /// Maximum amount in millisatoshis
+    #[serde(rename = "maxSendable")]
+    pub max_sendable: u64,
+    /// Metadata string (JSON stringified)
+    pub metadata: String,
+    /// Short description tag (should be "payRequest")
+    pub tag: Option<String>,
+    /// Optional error reason
+    pub reason: Option<String>,
+}
+
+/// LNURL-pay invoice response from the callback
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct LnurlPayInvoiceResponse {
+    /// The BOLT11 payment request (invoice)
+    pub pr: Option<String>,
+    /// Optional success action
+    pub success_action: Option<serde_json::Value>,
+    /// Optional routes (deprecated)
+    pub routes: Option<Vec<serde_json::Value>>,
+    /// Optional error reason
+    pub reason: Option<String>,
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_lightning_address_parsing() {
+        let addr = LightningAddress::from_str("satoshi@bitcoin.org").unwrap();
+        assert_eq!(addr.user, "satoshi");
+        assert_eq!(addr.domain, "bitcoin.org");
+        assert_eq!(addr.to_string(), "satoshi@bitcoin.org");
+    }
+
+    #[test]
+    fn test_lightning_address_to_url() {
+        let addr = LightningAddress {
+            user: "alice".to_string(),
+            domain: "example.com".to_string(),
+        };
+
+        let url = addr.to_url().unwrap();
+        assert_eq!(url.as_str(), "https://example.com/.well-known/lnurlp/alice");
+    }
+
+    #[test]
+    fn test_invalid_lightning_address() {
+        assert!(LightningAddress::from_str("invalid").is_err());
+        assert!(LightningAddress::from_str("@example.com").is_err());
+        assert!(LightningAddress::from_str("user@").is_err());
+        assert!(LightningAddress::from_str("user").is_err());
+    }
+}

+ 90 - 0
crates/cdk/src/wallet/melt/melt_lightning_address.rs

@@ -0,0 +1,90 @@
+//! Melt Lightning Address
+//!
+//! Implementation of melt functionality for Lightning addresses
+
+use std::str::FromStr;
+
+use cdk_common::wallet::MeltQuote;
+use tracing::instrument;
+
+use crate::lightning_address::LightningAddress;
+use crate::{Amount, Error, Wallet};
+
+impl Wallet {
+    /// Melt Quote for Lightning address
+    ///
+    /// This method resolves a Lightning address (e.g., "alice@example.com") to a Lightning invoice
+    /// and then creates a melt quote for that invoice.
+    ///
+    /// # Arguments
+    ///
+    /// * `lightning_address` - Lightning 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 Lightning address format is invalid
+    /// - HTTP request to the Lightning address service fails
+    /// - The amount is outside the acceptable range
+    /// - The service returns an error
+    /// - The mint fails to provide a quote for the invoice
+    ///
+    /// # Example
+    ///
+    /// ```rust,no_run
+    /// use cdk::Amount;
+    /// # use cdk::Wallet;
+    /// # async fn example(wallet: Wallet) -> Result<(), cdk::Error> {
+    /// let quote = wallet
+    ///     .melt_lightning_address_quote("alice@example.com", Amount::from(100_000)) // 100 sats in msat
+    ///     .await?;
+    /// # Ok(())
+    /// # }
+    /// ```
+    #[instrument(skip(self, amount_msat), fields(lightning_address = %lightning_address))]
+    pub async fn melt_lightning_address_quote(
+        &self,
+        lightning_address: &str,
+        amount_msat: impl Into<Amount>,
+    ) -> Result<MeltQuote, Error> {
+        let amount = amount_msat.into();
+
+        // Parse the Lightning address
+        let ln_address = LightningAddress::from_str(lightning_address).map_err(|e| {
+            tracing::error!(
+                "Failed to parse Lightning address '{}': {}",
+                lightning_address,
+                e
+            );
+            Error::LightningAddressParse(e.to_string())
+        })?;
+
+        tracing::debug!("Resolving Lightning address: {}", ln_address);
+
+        // Request an invoice from the Lightning address service
+        let invoice = ln_address
+            .request_invoice(&self.client, amount)
+            .await
+            .map_err(|e| {
+                tracing::error!(
+                    "Failed to get invoice from Lightning address service: {}",
+                    e
+                );
+                Error::LightningAddressRequest(e.to_string())
+            })?;
+
+        tracing::debug!(
+            "Received invoice from Lightning address service: {}",
+            invoice
+        );
+
+        // Create a melt quote for the invoice using the existing bolt11 functionality
+        // The invoice from LNURL already contains the amount, so we don't need amountless options
+        self.melt_quote(invoice.to_string(), None).await
+    }
+}

+ 63 - 0
crates/cdk/src/wallet/melt/mod.rs

@@ -11,6 +11,8 @@ use crate::Wallet;
 mod melt_bip353;
 mod melt_bolt11;
 mod melt_bolt12;
+#[cfg(feature = "wallet")]
+mod melt_lightning_address;
 
 impl Wallet {
     /// Check pending melt quotes
@@ -84,4 +86,65 @@ impl Wallet {
         }
         Ok(())
     }
+
+    /// Get a melt quote for a human-readable address
+    ///
+    /// This method accepts a human-readable address that could be either a BIP353 address
+    /// or a Lightning address. It intelligently determines which to try based on mint support:
+    ///
+    /// 1. If the mint supports Bolt12, it tries BIP353 first
+    /// 2. Falls back to Lightning address only if BIP353 DNS resolution fails
+    /// 3. If BIP353 resolves but fails at the mint, it does NOT fall back to Lightning address
+    /// 4. If the mint doesn't support Bolt12, it tries Lightning address directly
+    #[cfg(all(feature = "bip353", feature = "wallet", not(target_arch = "wasm32")))]
+    pub async fn melt_human_readable_quote(
+        &self,
+        address: &str,
+        amount_msat: impl Into<crate::Amount>,
+    ) -> Result<MeltQuote, Error> {
+        use cdk_common::nuts::PaymentMethod;
+
+        let amount = amount_msat.into();
+
+        // Get mint info from cache to check bolt12 support (no network call)
+        let mint_info = &self
+            .metadata_cache
+            .load(&self.localstore, &self.client, {
+                let ttl = self.metadata_cache_ttl.read();
+                *ttl
+            })
+            .await?
+            .mint_info;
+
+        // Check if mint supports bolt12 by looking at nut05 methods
+        let supports_bolt12 = mint_info
+            .nuts
+            .nut05
+            .methods
+            .iter()
+            .any(|m| m.method == PaymentMethod::Bolt12);
+
+        if supports_bolt12 {
+            // Mint supports bolt12, try BIP353 first
+            match self.melt_bip353_quote(address, amount).await {
+                Ok(quote) => Ok(quote),
+                Err(Error::Bip353Resolve(_)) => {
+                    // DNS resolution failed, fall back to Lightning address
+                    tracing::debug!(
+                        "BIP353 DNS resolution failed for {}, trying Lightning address",
+                        address
+                    );
+                    return self.melt_lightning_address_quote(address, amount).await;
+                }
+                Err(e) => {
+                    // BIP353 resolved but failed for another reason (e.g., mint error)
+                    // Don't fall back to Lightning address
+                    Err(e)
+                }
+            }
+        } else {
+            // Mint doesn't support bolt12, use Lightning address directly
+            self.melt_lightning_address_quote(address, amount).await
+        }
+    }
 }

+ 22 - 0
crates/cdk/src/wallet/mint_connector/http_client.rs

@@ -216,6 +216,28 @@ where
         self.transport.resolve_dns_txt(domain).await
     }
 
+    /// Fetch Lightning address pay request data
+    #[instrument(skip(self))]
+    async fn fetch_lnurl_pay_request(
+        &self,
+        url: &str,
+    ) -> Result<crate::lightning_address::LnurlPayResponse, Error> {
+        let parsed_url =
+            url::Url::parse(url).map_err(|e| Error::Custom(format!("Invalid URL: {}", e)))?;
+        self.transport.http_get(parsed_url, None).await
+    }
+
+    /// Fetch invoice from Lightning address callback
+    #[instrument(skip(self))]
+    async fn fetch_lnurl_invoice(
+        &self,
+        url: &str,
+    ) -> Result<crate::lightning_address::LnurlPayInvoiceResponse, Error> {
+        let parsed_url =
+            url::Url::parse(url).map_err(|e| Error::Custom(format!("Invalid URL: {}", e)))?;
+        self.transport.http_get(parsed_url, None).await
+    }
+
     /// Get Active Mint Keys [NUT-01]
     #[instrument(skip(self), fields(mint_url = %self.mint_url))]
     async fn get_mint_keys(&self) -> Result<Vec<KeySet>, Error> {

+ 14 - 0
crates/cdk/src/wallet/mint_connector/mod.rs

@@ -6,6 +6,8 @@ use async_trait::async_trait;
 use cdk_common::{MeltQuoteBolt12Request, MintQuoteBolt12Request, MintQuoteBolt12Response};
 
 use super::Error;
+// Re-export Lightning address types for trait implementers
+pub use crate::lightning_address::{LnurlPayInvoiceResponse, LnurlPayResponse};
 use crate::nuts::{
     CheckStateRequest, CheckStateResponse, Id, KeySet, KeysetResponse, MeltQuoteBolt11Request,
     MeltQuoteBolt11Response, MeltRequest, MintInfo, MintQuoteBolt11Request,
@@ -35,6 +37,18 @@ pub trait MintConnector: Debug {
     /// Resolve the DNS record getting the TXT value
     async fn resolve_dns_txt(&self, _domain: &str) -> Result<Vec<String>, Error>;
 
+    /// Fetch Lightning address pay request data
+    async fn fetch_lnurl_pay_request(
+        &self,
+        url: &str,
+    ) -> Result<crate::lightning_address::LnurlPayResponse, Error>;
+
+    /// Fetch invoice from Lightning address callback
+    async fn fetch_lnurl_invoice(
+        &self,
+        url: &str,
+    ) -> Result<crate::lightning_address::LnurlPayInvoiceResponse, Error>;
+
     /// Get Active Mint Keys [NUT-01]
     async fn get_mint_keys(&self) -> Result<Vec<KeySet>, Error>;
     /// Get Keyset Keys [NUT-01]

+ 1 - 1
crates/cdk/src/wallet/mod.rs

@@ -70,7 +70,7 @@ pub use mint_connector::http_client::HttpClient as BaseHttpClient;
 pub use mint_connector::transport::Transport as HttpTransport;
 #[cfg(feature = "auth")]
 pub use mint_connector::AuthHttpClient;
-pub use mint_connector::{HttpClient, MintConnector};
+pub use mint_connector::{HttpClient, LnurlPayInvoiceResponse, LnurlPayResponse, MintConnector};
 pub use multi_mint_wallet::{MultiMintReceiveOptions, MultiMintSendOptions, MultiMintWallet};
 pub use receive::ReceiveOptions;
 pub use send::{PreparedSend, SendMemo, SendOptions};