Skip to content

Commit 20ef094

Browse files
committed
feat: integrate BIP 353 dns payment instructions
1 parent b9cf2ac commit 20ef094

File tree

7 files changed

+436
-5
lines changed

7 files changed

+436
-5
lines changed

Cargo.lock

Lines changed: 93 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ shlex = { version = "1.3.0", optional = true }
3636
payjoin = { version = "1.0.0-rc.1", features = ["v1", "v2", "io", "_test-utils"], optional = true}
3737
reqwest = { version = "0.12.23", default-features = false, optional = true }
3838
url = { version = "2.5.4", optional = true }
39+
bitcoin-payment-instructions = { version = "0.7.0", optional = true}
3940

4041
[features]
4142
default = ["repl", "sqlite"]
@@ -51,7 +52,8 @@ redb = ["bdk_redb"]
5152
cbf = ["bdk_kyoto", "_payjoin-dependencies"]
5253
electrum = ["bdk_electrum", "_payjoin-dependencies"]
5354
esplora = ["bdk_esplora", "_payjoin-dependencies"]
54-
rpc = ["bdk_bitcoind_rpc", "_payjoin-dependencies"]
55+
rpc = ["bdk_bitcoind_rpc", "_payjoin-dependencies"]
56+
dns_payment = ["bitcoin-payment-instructions"]
5557

5658
# Internal features
5759
_payjoin-dependencies = ["payjoin", "reqwest", "url"]

src/commands.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ use bdk_wallet::bitcoin::{
1919
};
2020
use clap::{Args, Parser, Subcommand, ValueEnum, value_parser};
2121

22+
#[cfg(feature = "dns_payment")]
23+
use crate::utils::parse_dns_recipients;
2224
#[cfg(any(feature = "electrum", feature = "esplora", feature = "rpc"))]
2325
use crate::utils::parse_proxy_auth;
2426
use crate::utils::{parse_address, parse_outpoint, parse_recipient};
@@ -127,6 +129,10 @@ pub enum CliSubCommand {
127129
},
128130
/// List all saved wallet configurations.
129131
Wallets,
132+
133+
#[cfg(feature = "dns_payment")]
134+
/// Resolves the given hrn payment instructions
135+
ResolveDnsRecipient { hrn: String },
130136
}
131137
/// Wallet operation subcommands.
132138
#[derive(Debug, Subcommand, Clone, PartialEq)]
@@ -298,6 +304,10 @@ pub enum OfflineWalletSubCommand {
298304
// Address and amount parsing is done at run time in handler function.
299305
#[arg(env = "ADDRESS:SAT", long = "to", required = true, value_parser = parse_recipient)]
300306
recipients: Vec<(ScriptBuf, u64)>,
307+
#[cfg(feature = "dns_payment")]
308+
/// Adds DNS recipients to the transaction
309+
#[arg(long = "to_dns", value_parser = parse_dns_recipients)]
310+
dns_recipients: Vec<(String, u64)>,
301311
/// Sends all the funds (or all the selected utxos). Requires only one recipient with value 0.
302312
#[arg(long = "send_all", short = 'a')]
303313
send_all: bool,

src/dns_payment_instructions.rs

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
use bdk_wallet::bitcoin::{Address, Amount, Network};
2+
use bitcoin_payment_instructions::{
3+
FixedAmountPaymentInstructions, ParseError, PaymentInstructions, PaymentMethod,
4+
PaymentMethodType, amount, dns_resolver::DNSHrnResolver,
5+
};
6+
use core::{net::SocketAddr, str::FromStr};
7+
8+
use crate::error::BDKCliError;
9+
10+
#[derive(Debug)]
11+
pub struct Payment {
12+
pub payment_methods: Vec<PaymentMethod>,
13+
pub min_amount: Option<Amount>,
14+
pub max_amount: Option<Amount>,
15+
pub description: Option<String>,
16+
pub expected_amount: Option<Amount>,
17+
pub receiving_addr: Option<Address>,
18+
pub notes: String,
19+
}
20+
21+
pub(crate) async fn parse_dns_instructions(
22+
hrn: &str,
23+
network: Network,
24+
) -> Result<PaymentInstructions, ParseError> {
25+
let resolver = DNSHrnResolver(SocketAddr::from_str("8.8.8.8:53").expect("Should not fail."));
26+
let instructions = PaymentInstructions::parse(hrn, network, &resolver, true).await?;
27+
Ok(instructions)
28+
}
29+
30+
fn get_onchain_info(
31+
instructions: &FixedAmountPaymentInstructions,
32+
) -> Result<(Address, Amount), BDKCliError> {
33+
// Look for on chain payment method as it's the only one we can support
34+
let PaymentMethod::OnChain(addr) = instructions
35+
.methods()
36+
.iter()
37+
.find(|ix| matches!(ix, PaymentMethod::OnChain(_)))
38+
.ok_or(BDKCliError::Generic(
39+
"Missing Onchain payment method option.".to_string(),
40+
))?
41+
else {
42+
return Err(BDKCliError::Generic(
43+
"Unsupported payment method".to_string(),
44+
));
45+
};
46+
47+
let Some(onchain_amount) = instructions.onchain_payment_amount() else {
48+
return Err(BDKCliError::Generic(
49+
"On chain amount should be specified".to_string(),
50+
));
51+
};
52+
53+
// We need this conversion since Amount from instructions is different from Amount from bitcoin
54+
Ok((addr.clone(), Amount::from_sat(onchain_amount.milli_sats())))
55+
}
56+
57+
pub async fn process_instructions(
58+
amount_to_send: Amount,
59+
payment_instructions: &PaymentInstructions,
60+
) -> Result<Payment, BDKCliError> {
61+
let resolver = DNSHrnResolver(SocketAddr::from_str("8.8.8.8:53").expect("Should not fail."));
62+
63+
match payment_instructions {
64+
PaymentInstructions::ConfigurableAmount(instructions) => {
65+
// Look for on chain payment method as it's the only one we can support
66+
if !instructions
67+
.methods()
68+
.any(|method| matches!(method.method_type(), PaymentMethodType::OnChain))
69+
{
70+
return Err(BDKCliError::Generic(
71+
"Unsupported payment method".to_string(),
72+
));
73+
}
74+
75+
let min_amount = instructions
76+
.min_amt()
77+
.map(|amnt| Amount::from_sat(amnt.milli_sats()));
78+
79+
let max_amount = instructions
80+
.max_amt()
81+
.map(|amnt| Amount::from_sat(amnt.milli_sats()));
82+
83+
if min_amount.is_some_and(|min| amount_to_send < min) {
84+
return Err(BDKCliError::Generic(
85+
format!(
86+
"Amount to send should be greater than min {}",
87+
min_amount.unwrap()
88+
)
89+
.to_string(),
90+
));
91+
}
92+
93+
if max_amount.is_some_and(|max| amount_to_send > max) {
94+
return Err(BDKCliError::Generic(
95+
format!(
96+
"Amount to send should be lower than max {}",
97+
max_amount.unwrap()
98+
)
99+
.to_string(),
100+
));
101+
}
102+
103+
let fixed_instructions = instructions
104+
.clone()
105+
.set_amount(
106+
amount::Amount::from_sats(amount_to_send.to_sat()).unwrap(),
107+
&resolver,
108+
)
109+
.await
110+
.map_err(|err| {
111+
BDKCliError::Generic(format!("Error occured while parsing instructions {err}"))
112+
})?;
113+
114+
let onchain_details = get_onchain_info(&fixed_instructions)?;
115+
116+
Ok(Payment {
117+
payment_methods: vec![PaymentMethod::OnChain(onchain_details.clone().0)],
118+
min_amount,
119+
max_amount,
120+
description: instructions.recipient_description().map(|s| s.to_string()),
121+
expected_amount: Some(onchain_details.1),
122+
receiving_addr: Some(onchain_details.0.clone()),
123+
notes: "".to_string(),
124+
})
125+
}
126+
127+
PaymentInstructions::FixedAmount(instructions) => {
128+
let onchain_info = get_onchain_info(instructions)?;
129+
130+
Ok(Payment {
131+
payment_methods: vec![PaymentMethod::OnChain(onchain_info.clone().0)],
132+
min_amount: None,
133+
max_amount: instructions
134+
.max_amount()
135+
.map(|amnt| Amount::from_sat(amnt.milli_sats())),
136+
description: instructions.recipient_description().map(|s| s.to_string()),
137+
expected_amount: Some(onchain_info.1),
138+
receiving_addr: Some(onchain_info.0),
139+
notes: "".to_string(),
140+
})
141+
}
142+
}
143+
}
144+
145+
/// Resolves the dns payment instructions found at the specified Human Readable Name
146+
pub async fn resolve_dns_recipient(hrn: &str, network: Network) -> Result<Payment, ParseError> {
147+
let instructions = parse_dns_instructions(hrn, network).await?;
148+
let resolver = DNSHrnResolver(SocketAddr::from_str("8.8.8.8:53").expect("Should not fail."));
149+
150+
match instructions {
151+
PaymentInstructions::ConfigurableAmount(ix) => {
152+
let description = ix.recipient_description().map(|s| s.to_string());
153+
let min_amount = ix.min_amt().map(|amnt| Amount::from_sat(amnt.milli_sats()));
154+
let max_amount = ix.max_amt().map(|amnt| Amount::from_sat(amnt.milli_sats()));
155+
156+
// Let's set a dummy amount to resolve the payment methods accepted.
157+
let fixed_instructions = ix
158+
.set_amount(amount::Amount::ZERO, &resolver)
159+
.await
160+
.map_err(ParseError::InvalidInstructions)?;
161+
162+
let payment = Payment {
163+
min_amount,
164+
max_amount,
165+
payment_methods: fixed_instructions.methods().into(),
166+
description,
167+
expected_amount: None,
168+
receiving_addr: None,
169+
notes: "This is configurable payment instructions. You must send an amount between min_amount and max_amount if set.".to_string()
170+
};
171+
172+
Ok(payment)
173+
}
174+
175+
PaymentInstructions::FixedAmount(ix) => {
176+
let max_amount = ix
177+
.max_amount()
178+
.map(|amnt| Amount::from_sat(amnt.milli_sats()));
179+
180+
let payment = Payment {
181+
min_amount: None,
182+
max_amount,
183+
payment_methods: ix.methods().into(),
184+
description: ix.recipient_description().map(|s| s.to_string()),
185+
expected_amount: None,
186+
receiving_addr: None,
187+
notes: "This is a fixed payment instructions. You must send exactly the amount specified in max_amount.".to_string()
188+
};
189+
190+
Ok(payment)
191+
}
192+
}
193+
}

0 commit comments

Comments
 (0)