mirror of
https://github.com/aljazceru/cdk.git
synced 2026-02-08 14:45:47 +01:00
feat: bip353 (#969)
This commit is contained in:
@@ -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 }
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
crates/cdk/examples/bip353.rs
Normal file
143
crates/cdk/examples/bip353.rs
Normal file
@@ -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
crates/cdk/src/bip353.rs
Normal file
286
crates/cdk/src/bip353.rs
Normal file
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
crates/cdk/src/wallet/melt/melt_bip353.rs
Normal file
94
crates/cdk/src/wallet/melt/melt_bip353.rs
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ use tracing::instrument;
|
||||
|
||||
use crate::Wallet;
|
||||
|
||||
#[cfg(feature = "bip353")]
|
||||
mod melt_bip353;
|
||||
mod melt_bolt11;
|
||||
mod melt_bolt12;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user