Skip to content

Commit 6724be5

Browse files
committed
feat: integrate BIP 353 dns payment instructions
1 parent b9cf2ac commit 6724be5

File tree

7 files changed

+437
-5
lines changed

7 files changed

+437
-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: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
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+
.find(|method| matches!(method.method_type(), PaymentMethodType::OnChain))
69+
.is_none()
70+
{
71+
return Err(BDKCliError::Generic(
72+
"Unsupported payment method".to_string(),
73+
));
74+
}
75+
76+
let min_amount = instructions
77+
.min_amt()
78+
.map(|amnt| Amount::from_sat(amnt.milli_sats()));
79+
80+
let max_amount = instructions
81+
.max_amt()
82+
.map(|amnt| Amount::from_sat(amnt.milli_sats()));
83+
84+
if min_amount.map_or(false, |min| amount_to_send < min) {
85+
return Err(BDKCliError::Generic(
86+
format!(
87+
"Amount to send should be greater than min {}",
88+
min_amount.unwrap()
89+
)
90+
.to_string(),
91+
));
92+
}
93+
94+
if max_amount.map_or(false, |max| amount_to_send > max) {
95+
return Err(BDKCliError::Generic(
96+
format!(
97+
"Amount to send should be lower than max {}",
98+
max_amount.unwrap()
99+
)
100+
.to_string(),
101+
));
102+
}
103+
104+
let fixed_instructions = instructions
105+
.clone()
106+
.set_amount(
107+
amount::Amount::from_sats(amount_to_send.to_sat()).unwrap(),
108+
&resolver,
109+
)
110+
.await
111+
.map_err(|err| {
112+
BDKCliError::Generic(format!("Error occured while parsing instructions {err}"))
113+
})?;
114+
115+
let onchain_details = get_onchain_info(&fixed_instructions)?;
116+
117+
Ok(Payment {
118+
payment_methods: vec![PaymentMethod::OnChain(onchain_details.clone().0)],
119+
min_amount,
120+
max_amount,
121+
description: instructions.recipient_description().map(|s| s.to_string()),
122+
expected_amount: Some(onchain_details.1),
123+
receiving_addr: Some(onchain_details.0.clone()),
124+
notes: "".to_string(),
125+
})
126+
}
127+
128+
PaymentInstructions::FixedAmount(instructions) => {
129+
let onchain_info = get_onchain_info(&instructions)?;
130+
131+
Ok(Payment {
132+
payment_methods: vec![PaymentMethod::OnChain(onchain_info.clone().0)],
133+
min_amount: None,
134+
max_amount: instructions
135+
.max_amount()
136+
.map(|amnt| Amount::from_sat(amnt.milli_sats())),
137+
description: instructions.recipient_description().map(|s| s.to_string()),
138+
expected_amount: Some(onchain_info.1),
139+
receiving_addr: Some(onchain_info.0),
140+
notes: "".to_string(),
141+
})
142+
}
143+
}
144+
}
145+
146+
/// Resolves the dns payment instructions found at the specified Human Readable Name
147+
pub async fn resolve_dns_recipient(hrn: &str, network: Network) -> Result<Payment, ParseError> {
148+
let instructions = parse_dns_instructions(hrn, network).await?;
149+
let resolver = DNSHrnResolver(SocketAddr::from_str("8.8.8.8:53").expect("Should not fail."));
150+
151+
match instructions {
152+
PaymentInstructions::ConfigurableAmount(ix) => {
153+
let description = ix.recipient_description().map(|s| s.to_string());
154+
let min_amount = ix.min_amt().map(|amnt| Amount::from_sat(amnt.milli_sats()));
155+
let max_amount = ix.max_amt().map(|amnt| Amount::from_sat(amnt.milli_sats()));
156+
157+
// Let's set a dummy amount to resolve the payment methods accepted.
158+
let fixed_instructions = ix
159+
.set_amount(amount::Amount::ZERO, &resolver)
160+
.await
161+
.map_err(ParseError::InvalidInstructions)?;
162+
163+
let payment = Payment {
164+
min_amount,
165+
max_amount,
166+
payment_methods: fixed_instructions.methods().into(),
167+
description,
168+
expected_amount: None,
169+
receiving_addr: None,
170+
notes: "This is configurable payment instructions. You must send an amount between min_amount and max_amount if set.".to_string()
171+
};
172+
173+
Ok(payment)
174+
}
175+
176+
PaymentInstructions::FixedAmount(ix) => {
177+
let max_amount = ix
178+
.max_amount()
179+
.map(|amnt| Amount::from_sat(amnt.milli_sats()));
180+
181+
let payment = Payment {
182+
min_amount: None,
183+
max_amount,
184+
payment_methods: ix.methods().into(),
185+
description: ix.recipient_description().map(|s| s.to_string()),
186+
expected_amount: None,
187+
receiving_addr: None,
188+
notes: "This is a fixed payment instructions. You must send exactly the amount specified in max_amount.".to_string()
189+
};
190+
191+
Ok(payment)
192+
}
193+
}
194+
}

0 commit comments

Comments
 (0)