Skip to content

Commit cb8d0d5

Browse files
committed
feat: add Payjoin send without persistence
1 parent eb7e998 commit cb8d0d5

File tree

9 files changed

+1108
-25
lines changed

9 files changed

+1108
-25
lines changed

Cargo.lock

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

Cargo.toml

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ serde_json = "1.0"
2121
thiserror = "2.0.11"
2222
tokio = { version = "1", features = ["full"] }
2323
cli-table = "0.5.0"
24+
tracing = "0.1.41"
25+
tracing-subscriber = "0.3.20"
2426

2527
# Optional dependencies
2628
bdk_bitcoind_rpc = { version = "0.21.0", features = ["std"], optional = true }
@@ -29,8 +31,9 @@ bdk_esplora = { version = "0.22.1", features = ["async-https", "tokio"], optiona
2931
bdk_kyoto = { version = "0.15.1", optional = true }
3032
bdk_redb = { version = "0.1.0", optional = true }
3133
shlex = { version = "1.3.0", optional = true }
32-
tracing = "0.1.41"
33-
tracing-subscriber = "0.3.20"
34+
payjoin = { version = "1.0.0-rc.0", features = ["v1", "v2", "io", "_test-utils"], optional = true}
35+
reqwest = { version = "0.12.23", default-features = false, optional = true }
36+
url = { version = "2.5.4", optional = true }
3437

3538
[features]
3639
default = ["repl", "sqlite"]
@@ -43,10 +46,13 @@ sqlite = ["bdk_wallet/rusqlite"]
4346
redb = ["bdk_redb"]
4447

4548
# Available blockchain client options
46-
cbf = ["bdk_kyoto"]
47-
electrum = ["bdk_electrum"]
48-
esplora = ["bdk_esplora"]
49-
rpc = ["bdk_bitcoind_rpc"]
49+
cbf = ["bdk_kyoto", "_payjoin-dependencies"]
50+
electrum = ["bdk_electrum", "_payjoin-dependencies"]
51+
esplora = ["bdk_esplora", "_payjoin-dependencies"]
52+
rpc = ["bdk_bitcoind_rpc", "_payjoin-dependencies"]
53+
54+
# Internal features
55+
_payjoin-dependencies = ["payjoin", "reqwest", "url"]
5056

5157
# Use this to consensus verify transactions at sync time
5258
verify = []

src/commands.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,19 @@ pub enum OnlineWalletSubCommand {
416416
)]
417417
tx: Option<String>,
418418
},
419+
/// Sends an original PSBT to a BIP 21 URI and broadcasts the returned PayJoin PSBT.
420+
SendPayjoin {
421+
/// BIP 21 URI for the Payjoin (either v1 or v2).
422+
#[arg(env = "PAYJOIN_URI", long = "uri", required = true)]
423+
uri: String,
424+
/// Fee rate to use in sat/vbyte.
425+
#[arg(env = "SATS_VBYTE", short = 'f', long = "fee_rate", required = true)]
426+
fee_rate: u64,
427+
/// URL of the Payjoin OHTTP relay. Can be repeated multiple times to attempt the
428+
/// operation with multiple relays for redundancy.
429+
#[arg(env = "PAYJOIN_OHTTP_RELAY", long = "ohttp_relay")]
430+
ohttp_relay: Option<Vec<String>>,
431+
},
419432
}
420433

421434
/// Subcommands for Key operations.

src/error.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,15 @@ pub enum BDKCliError {
103103
#[cfg(feature = "cbf")]
104104
#[error("BDK-Kyoto update error: {0}")]
105105
KyotoUpdateError(#[from] bdk_kyoto::UpdateError),
106+
107+
#[cfg(any(
108+
feature = "electrum",
109+
feature = "esplora",
110+
feature = "rpc",
111+
feature = "cbf",
112+
))]
113+
#[error("Reqwest error: {0}")]
114+
ReqwestError(#[from] reqwest::Error),
106115
}
107116

108117
impl From<ExtractTxError> for BDKCliError {

src/handlers.rs

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
//! Command Handlers
1010
//!
1111
//! This module describes all the command handling logic used by bdk-cli.
12-
1312
use crate::commands::OfflineWalletSubCommand::*;
1413
use crate::commands::*;
1514
use crate::error::BDKCliError as Error;
@@ -52,7 +51,14 @@ use std::convert::TryFrom;
5251
#[cfg(any(feature = "repl", feature = "electrum", feature = "esplora"))]
5352
use std::io::Write;
5453
use std::str::FromStr;
55-
#[cfg(any(feature = "redb", feature = "compiler"))]
54+
#[cfg(any(
55+
feature = "redb",
56+
feature = "compiler",
57+
feature = "electrum",
58+
feature = "esplora",
59+
feature = "cbf",
60+
feature = "rpc"
61+
))]
5662
use std::sync::Arc;
5763

5864
#[cfg(feature = "electrum")]
@@ -72,7 +78,9 @@ use tokio::select;
7278
))]
7379
use {
7480
crate::commands::OnlineWalletSubCommand::*,
81+
crate::payjoin::{PayjoinManager, ohttp::RelayManager},
7582
bdk_wallet::bitcoin::{Transaction, consensus::Decodable, hex::FromHex},
83+
std::sync::Mutex,
7684
};
7785
#[cfg(feature = "esplora")]
7886
use {crate::utils::BlockchainClient::Esplora, bdk_esplora::EsploraAsyncExt};
@@ -783,9 +791,20 @@ pub(crate) async fn handle_online_wallet_subcommand(
783791
(Some(_), Some(_)) => panic!("Both `psbt` and `tx` options not allowed"),
784792
(None, None) => panic!("Missing `psbt` and `tx` option"),
785793
};
786-
let txid = broadcast_transaction(client, tx).await?;
794+
let txid = broadcast_transaction(&client, tx).await?;
787795
Ok(serde_json::to_string_pretty(&json!({ "txid": txid }))?)
788796
}
797+
SendPayjoin {
798+
uri,
799+
fee_rate,
800+
ohttp_relay,
801+
} => {
802+
let relay_manager = Arc::new(Mutex::new(RelayManager::new()));
803+
let mut payjoin_manager = PayjoinManager::new(&client, wallet, relay_manager);
804+
return payjoin_manager
805+
.send_payjoin(uri, fee_rate, ohttp_relay)
806+
.await;
807+
}
789808
}
790809
}
791810

@@ -1269,7 +1288,10 @@ async fn respond(
12691288
feature = "rpc"
12701289
))]
12711290
/// Broadcasts a given transaction using the blockchain client.
1272-
async fn broadcast_transaction(client: BlockchainClient, tx: Transaction) -> Result<Txid, Error> {
1291+
pub async fn broadcast_transaction(
1292+
client: &BlockchainClient,
1293+
tx: Transaction,
1294+
) -> Result<Txid, Error> {
12731295
match client {
12741296
#[cfg(feature = "electrum")]
12751297
Electrum {

src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
mod commands;
1414
mod error;
1515
mod handlers;
16+
mod payjoin;
1617
#[cfg(any(feature = "sqlite", feature = "redb"))]
1718
mod persister;
1819
mod utils;

src/payjoin/mod.rs

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
use crate::error::BDKCliError as Error;
2+
use crate::handlers::broadcast_transaction;
3+
use crate::utils::{BlockchainClient, send_payjoin_post_request};
4+
use bdk_wallet::{
5+
SignOptions, Wallet,
6+
bitcoin::{FeeRate, Psbt, Txid},
7+
};
8+
use payjoin::UriExt;
9+
use payjoin::persist::{OptionalTransitionOutcome, SessionPersister};
10+
use payjoin::send::v2::{
11+
PollingForProposal, SendSession, Sender, SessionEvent as SenderSessionEvent, WithReplyKey,
12+
};
13+
use serde_json::{json, to_string_pretty};
14+
use std::sync::{Arc, Mutex};
15+
16+
use crate::payjoin::ohttp::{RelayManager, fetch_ohttp_keys};
17+
18+
pub mod ohttp;
19+
20+
pub(crate) struct PayjoinManager<'a> {
21+
blockchain_client: &'a BlockchainClient,
22+
wallet: &'a mut Wallet,
23+
relay_manager: Arc<Mutex<RelayManager>>,
24+
// TODO: Implement persister!
25+
// persister: ...
26+
}
27+
28+
impl<'a> PayjoinManager<'a> {
29+
pub fn new(
30+
blockchain_client: &'a BlockchainClient,
31+
wallet: &'a mut Wallet,
32+
relay_manager: Arc<Mutex<RelayManager>>,
33+
) -> Self {
34+
Self {
35+
blockchain_client,
36+
wallet,
37+
relay_manager,
38+
}
39+
}
40+
41+
pub async fn send_payjoin(
42+
&mut self,
43+
uri: String,
44+
fee_rate: u64,
45+
ohttp_relay: Option<Vec<String>>,
46+
) -> Result<String, Error> {
47+
let uri = payjoin::Uri::try_from(uri)
48+
.map_err(|e| Error::Generic(format!("Failed parsing to Payjoin URI: {}", e)))?;
49+
let uri = uri.require_network(self.wallet.network()).map_err(|e| {
50+
Error::Generic(format!(
51+
"Failed setting the right network for the URI: {}",
52+
e
53+
))
54+
})?;
55+
let uri = uri
56+
.check_pj_supported()
57+
.map_err(|e| Error::Generic(format!("URI does not support Payjoin: {}", e)))?;
58+
59+
let sats = uri
60+
.amount
61+
.ok_or_else(|| Error::Generic("Amount is not specified in the URI.".to_string()))?;
62+
63+
let fee_rate = FeeRate::from_sat_per_vb(fee_rate).expect("Provided fee rate is not valid.");
64+
65+
// Build and sign the original PSBT which pays to the receiver.
66+
let mut original_psbt = {
67+
let mut tx_builder = self.wallet.build_tx();
68+
tx_builder
69+
.add_recipient(uri.address.script_pubkey(), sats)
70+
.fee_rate(fee_rate);
71+
72+
tx_builder.finish().map_err(|e| {
73+
Error::Generic(format!(
74+
"Error occurred when building original Payjoin transaction: {}",
75+
e
76+
))
77+
})?
78+
};
79+
if !self
80+
.wallet
81+
.sign(&mut original_psbt, SignOptions::default())?
82+
{
83+
return Err(Error::Generic(
84+
"Failed to sign and finalize the original PSBT.".to_string(),
85+
));
86+
}
87+
88+
let txid = match uri.extras.pj_param() {
89+
payjoin::PjParam::V1(_) => {
90+
let (req, ctx) = payjoin::send::v1::SenderBuilder::new(original_psbt.clone(), uri)
91+
.build_recommended(fee_rate)
92+
.map_err(|e| {
93+
Error::Generic(format!("Failed to build a Payjoin v1 sender: {}", e))
94+
})?
95+
.create_v1_post_request();
96+
97+
let response = send_payjoin_post_request(req)
98+
.await
99+
.map_err(|e| Error::Generic(format!("Failed to send request: {}", e)))?;
100+
101+
let psbt = ctx
102+
.process_response(&response.bytes().await?)
103+
.map_err(|e| Error::Generic(format!("Failed to send a Payjoin v1: {}", e)))?;
104+
105+
self.process_payjoin_proposal(psbt).await?
106+
}
107+
payjoin::PjParam::V2(_) => {
108+
// Validating all OHTTP relays before we go ahead and potentially use them.
109+
let ohttp_relays = match ohttp_relay {
110+
None => Ok(vec![]),
111+
Some(relays) => relays.into_iter().map(|s| url::Url::parse(&s)).collect(),
112+
}
113+
.map_err(|e| {
114+
Error::Generic(format!("Failed to parse one or more OHTTP URLs: {}", e))
115+
})?;
116+
117+
if ohttp_relays.is_empty() {
118+
return Err(Error::Generic(format!(
119+
"No OHTTP relays were provided with the Payjoin v2 URI."
120+
)));
121+
}
122+
123+
// TODO: Implement proper persister.
124+
let persister =
125+
payjoin::persist::NoopSessionPersister::<SenderSessionEvent>::default();
126+
let sender = payjoin::send::v2::SenderBuilder::new(original_psbt.clone(), uri)
127+
.build_recommended(fee_rate)
128+
.map_err(|e| {
129+
Error::Generic(format!("Failed to build a Payjoin v2 sender: {}", e))
130+
})?
131+
.save(&persister)
132+
.map_err(|e| {
133+
Error::Generic(format!(
134+
"Failed to save the Payjoin v2 sender in the persister: {}",
135+
e
136+
))
137+
})?;
138+
139+
let selected_relay =
140+
fetch_ohttp_keys(ohttp_relays, &sender.endpoint(), self.relay_manager.clone())
141+
.await?
142+
.relay_url;
143+
144+
self.proceed_sender_session(
145+
SendSession::WithReplyKey(sender),
146+
&persister,
147+
selected_relay,
148+
)
149+
.await?
150+
}
151+
_ => {
152+
unimplemented!("Payjoin version not recognized.");
153+
}
154+
};
155+
156+
Ok(to_string_pretty(&json!({ "txid": txid }))?)
157+
}
158+
159+
async fn proceed_sender_session(
160+
&self,
161+
session: SendSession,
162+
persister: &impl SessionPersister<SessionEvent = SenderSessionEvent>,
163+
relay: url::Url,
164+
) -> Result<Txid, Error> {
165+
match session {
166+
SendSession::WithReplyKey(context) => {
167+
self.post_original_proposal(context, relay, persister).await
168+
}
169+
SendSession::PollingForProposal(context) => {
170+
self.get_proposed_payjoin_proposal(context, relay, persister)
171+
.await
172+
}
173+
SendSession::ProposalReceived(psbt) => self.process_payjoin_proposal(psbt).await,
174+
_ => Err(Error::Generic("Unexpected SendSession state!".to_string())),
175+
}
176+
}
177+
178+
async fn post_original_proposal(
179+
&self,
180+
sender: Sender<WithReplyKey>,
181+
relay: url::Url,
182+
persister: &impl SessionPersister<SessionEvent = SenderSessionEvent>,
183+
) -> Result<Txid, Error> {
184+
let (req, ctx) = sender.create_v2_post_request(relay.as_str()).map_err(|e| {
185+
Error::Generic(format!(
186+
"Failed to create a post request for a Payjoin send: {}",
187+
e
188+
))
189+
})?;
190+
let response = send_payjoin_post_request(req).await?;
191+
let sender = sender
192+
.process_response(&response.bytes().await?, ctx)
193+
.save(persister)
194+
.map_err(|e| {
195+
Error::Generic(format!("Failed to persist the Payjoin send after successfully sending original proposal: {}", e))
196+
})?;
197+
self.get_proposed_payjoin_proposal(sender, relay, persister)
198+
.await
199+
}
200+
201+
async fn get_proposed_payjoin_proposal(
202+
&self,
203+
sender: Sender<PollingForProposal>,
204+
relay: url::Url,
205+
persister: &impl SessionPersister<SessionEvent = SenderSessionEvent>,
206+
) -> Result<Txid, Error> {
207+
let mut sender = sender.clone();
208+
loop {
209+
let (req, ctx) = sender.create_poll_request(relay.as_str()).map_err(|e| {
210+
Error::Generic(format!(
211+
"Failed to create a poll request during a Payjoin send: {}",
212+
e
213+
))
214+
})?;
215+
let response = send_payjoin_post_request(req).await?;
216+
let processed_response = sender
217+
.process_response(&response.bytes().await?, ctx)
218+
.save(persister);
219+
match processed_response {
220+
Ok(OptionalTransitionOutcome::Progress(psbt)) => {
221+
println!("Proposal received. Processing...");
222+
return self.process_payjoin_proposal(psbt).await;
223+
}
224+
Ok(OptionalTransitionOutcome::Stasis(current_state)) => {
225+
println!("No response yet. Continuing polling...");
226+
sender = current_state;
227+
continue;
228+
}
229+
Err(e) => {
230+
break Err(Error::Generic(format!(
231+
"Error occurred when polling for Payjoin v2 proposal: {}",
232+
e
233+
)));
234+
}
235+
}
236+
}
237+
}
238+
239+
async fn process_payjoin_proposal(&self, mut psbt: Psbt) -> Result<Txid, Error> {
240+
if !self.wallet.sign(&mut psbt, SignOptions::default())? {
241+
return Err(Error::Generic(
242+
"Failed to sign and finalize the Payjoin proposal PSBT.".to_string(),
243+
));
244+
}
245+
246+
broadcast_transaction(self.blockchain_client, psbt.extract_tx_fee_rate_limit()?).await
247+
}
248+
}

0 commit comments

Comments
 (0)