feat: bip353 (#969)

This commit is contained in:
thesimplekid
2025-08-21 16:16:48 +01:00
committed by GitHub
parent b6f7a75fba
commit 9a3d9b7139
11 changed files with 555 additions and 150 deletions

View File

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

View File

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

View File

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