Skip to content

Commit dca57c0

Browse files
committed
feat: add payjoin send support
1 parent f62287a commit dca57c0

File tree

8 files changed

+1008
-22
lines changed

8 files changed

+1008
-22
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: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,24 @@ pub enum OnlineWalletSubCommand {
430430
)]
431431
tx: Option<String>,
432432
},
433+
/// Sends an original PSBT to a BIP 21 URI and broadcasts the returned Payjoin PSBT.
434+
SendPayjoin {
435+
/// BIP 21 URI for the Payjoin.
436+
#[arg(env = "PAYJOIN_URI", long = "uri", required = true)]
437+
uri: String,
438+
/// URL of the Payjoin OHTTP relay. Can be repeated multiple times to attempt the
439+
/// operation with multiple relays for redundancy.
440+
#[arg(env = "PAYJOIN_OHTTP_RELAY", long = "ohttp_relay", required = true)]
441+
ohttp_relay: Vec<String>,
442+
/// Fee rate to use in sat/vbyte.
443+
#[arg(
444+
env = "PAYJOIN_SENDER_FEE_RATE",
445+
short = 'f',
446+
long = "fee_rate",
447+
required = true
448+
)]
449+
fee_rate: u64,
450+
},
433451
}
434452

435453
/// 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: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,14 @@ use std::convert::TryFrom;
5858
#[cfg(any(feature = "repl", feature = "electrum", feature = "esplora"))]
5959
use std::io::Write;
6060
use std::str::FromStr;
61-
#[cfg(any(feature = "redb", feature = "compiler"))]
61+
#[cfg(any(
62+
feature = "redb",
63+
feature = "compiler",
64+
feature = "electrum",
65+
feature = "esplora",
66+
feature = "cbf",
67+
feature = "rpc"
68+
))]
6269
use std::sync::Arc;
6370
#[cfg(any(
6471
feature = "electrum",
@@ -68,7 +75,9 @@ use std::sync::Arc;
6875
))]
6976
use {
7077
crate::commands::OnlineWalletSubCommand::*,
78+
crate::payjoin::{PayjoinManager, ohttp::RelayManager},
7179
bdk_wallet::bitcoin::{Transaction, consensus::Decodable, hex::FromHex},
80+
std::sync::Mutex,
7281
};
7382
#[cfg(feature = "esplora")]
7483
use {crate::utils::BlockchainClient::Esplora, bdk_esplora::EsploraAsyncExt};
@@ -706,6 +715,17 @@ pub(crate) async fn handle_online_wallet_subcommand(
706715
let txid = broadcast_transaction(client, tx).await?;
707716
Ok(serde_json::to_string_pretty(&json!({ "txid": txid }))?)
708717
}
718+
SendPayjoin {
719+
uri,
720+
ohttp_relay,
721+
fee_rate,
722+
} => {
723+
let relay_manager = Arc::new(Mutex::new(RelayManager::new()));
724+
let mut payjoin_manager = PayjoinManager::new(wallet, relay_manager);
725+
return payjoin_manager
726+
.send_payjoin(uri, fee_rate, ohttp_relay, client)
727+
.await;
728+
}
709729
}
710730
}
711731

src/main.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@
1313
mod commands;
1414
mod error;
1515
mod handlers;
16+
#[cfg(any(
17+
feature = "electrum",
18+
feature = "esplora",
19+
feature = "cbf",
20+
feature = "rpc"
21+
))]
22+
mod payjoin;
1623
#[cfg(any(feature = "sqlite", feature = "redb"))]
1724
mod persister;
1825
mod utils;

src/payjoin/mod.rs

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
use crate::error::BDKCliError as Error;
2+
use crate::handlers::{broadcast_transaction, sync_wallet};
3+
use crate::utils::BlockchainClient;
4+
use bdk_wallet::{
5+
SignOptions, Wallet,
6+
bitcoin::{FeeRate, Psbt, Txid, consensus::encode::serialize_hex},
7+
};
8+
use payjoin::bitcoin::TxIn;
9+
use payjoin::persist::{OptionalTransitionOutcome, SessionPersister};
10+
use payjoin::receive::InputPair;
11+
use payjoin::receive::v2::{
12+
HasReplyableError, Initialized, MaybeInputsOwned, MaybeInputsSeen, Monitor, OutputsUnknown,
13+
PayjoinProposal, ProvisionalProposal, ReceiveSession, Receiver,
14+
SessionEvent as ReceiverSessionEvent, UncheckedOriginalPayload, WantsFeeRange, WantsInputs,
15+
WantsOutputs,
16+
};
17+
use payjoin::send::v2::{
18+
PollingForProposal, SendSession, Sender, SessionEvent as SenderSessionEvent,
19+
SessionOutcome as SenderSessionOutcome, WithReplyKey,
20+
};
21+
use payjoin::{ImplementationError, UriExt};
22+
use serde_json::{json, to_string_pretty};
23+
use std::sync::{Arc, Mutex};
24+
25+
use crate::payjoin::ohttp::{RelayManager, fetch_ohttp_keys};
26+
27+
pub mod ohttp;
28+
29+
/// Implements all of the functions required to go through the Payjoin receive and send processes.
30+
///
31+
/// TODO: At the time of writing, this struct is written to make a Persister implementation easier
32+
/// but the persister is not implemented yet! For instance [`PayjoinManager::proceed_sender_session`] and
33+
/// [`PayjoinManager::proceed_receiver_session`] are designed such that the manager can enable
34+
/// resuming ongoing payjoins are well. So... this is a TODO for implementing persister.
35+
pub(crate) struct PayjoinManager<'a> {
36+
wallet: &'a mut Wallet,
37+
relay_manager: Arc<Mutex<RelayManager>>,
38+
}
39+
40+
impl<'a> PayjoinManager<'a> {
41+
pub fn new(wallet: &'a mut Wallet, relay_manager: Arc<Mutex<RelayManager>>) -> Self {
42+
Self {
43+
wallet,
44+
relay_manager,
45+
}
46+
}
47+
48+
async fn proceed_sender_session(
49+
&self,
50+
session: SendSession,
51+
persister: &impl SessionPersister<SessionEvent = SenderSessionEvent>,
52+
relay: url::Url,
53+
blockchain_client: BlockchainClient,
54+
) -> Result<Txid, Error> {
55+
match session {
56+
SendSession::WithReplyKey(context) => {
57+
self.post_original_proposal(context, relay, persister, blockchain_client)
58+
.await
59+
}
60+
SendSession::PollingForProposal(context) => {
61+
self.get_proposed_payjoin_proposal(context, relay, persister, blockchain_client)
62+
.await
63+
}
64+
SendSession::Closed(SenderSessionOutcome::Success(psbt)) => {
65+
self.process_payjoin_proposal(psbt, blockchain_client).await
66+
}
67+
_ => Err(Error::Generic("Unexpected SendSession state!".to_string())),
68+
}
69+
}
70+
71+
async fn post_original_proposal(
72+
&self,
73+
sender: Sender<WithReplyKey>,
74+
relay: url::Url,
75+
persister: &impl SessionPersister<SessionEvent = SenderSessionEvent>,
76+
blockchain_client: BlockchainClient,
77+
) -> Result<Txid, Error> {
78+
let (req, ctx) = sender.create_v2_post_request(relay.as_str()).map_err(|e| {
79+
Error::Generic(format!(
80+
"Failed to create a post request for a Payjoin send: {}",
81+
e
82+
))
83+
})?;
84+
let response = self.send_payjoin_post_request(req).await?;
85+
let sender = sender
86+
.process_response(&response.bytes().await?, ctx)
87+
.save(persister)
88+
.map_err(|e| {
89+
Error::Generic(format!("Failed to persist the Payjoin send after successfully sending original proposal: {}", e))
90+
})?;
91+
self.get_proposed_payjoin_proposal(sender, relay, persister, blockchain_client)
92+
.await
93+
}
94+
95+
async fn get_proposed_payjoin_proposal(
96+
&self,
97+
sender: Sender<PollingForProposal>,
98+
relay: url::Url,
99+
persister: &impl SessionPersister<SessionEvent = SenderSessionEvent>,
100+
blockchain_client: BlockchainClient,
101+
) -> Result<Txid, Error> {
102+
let mut sender = sender.clone();
103+
loop {
104+
let (req, ctx) = sender.create_poll_request(relay.as_str()).map_err(|e| {
105+
Error::Generic(format!(
106+
"Failed to create a poll request during a Payjoin send: {}",
107+
e
108+
))
109+
})?;
110+
let response = self.send_payjoin_post_request(req).await?;
111+
let processed_response = sender
112+
.process_response(&response.bytes().await?, ctx)
113+
.save(persister);
114+
match processed_response {
115+
Ok(OptionalTransitionOutcome::Progress(psbt)) => {
116+
println!("Proposal received. Processing...");
117+
return self.process_payjoin_proposal(psbt, blockchain_client).await;
118+
}
119+
Ok(OptionalTransitionOutcome::Stasis(current_state)) => {
120+
println!("No response yet. Continuing polling...");
121+
sender = current_state;
122+
continue;
123+
}
124+
Err(e) => {
125+
break Err(Error::Generic(format!(
126+
"Error occurred when polling for Payjoin v2 proposal: {}",
127+
e
128+
)));
129+
}
130+
}
131+
}
132+
}
133+
134+
async fn process_payjoin_proposal(
135+
&self,
136+
mut psbt: Psbt,
137+
blockchain_client: BlockchainClient,
138+
) -> Result<Txid, Error> {
139+
if !self.wallet.sign(&mut psbt, SignOptions::default())? {
140+
return Err(Error::Generic(
141+
"Failed to sign and finalize the Payjoin proposal PSBT.".to_string(),
142+
));
143+
}
144+
145+
broadcast_transaction(blockchain_client, psbt.extract_tx_fee_rate_limit()?).await
146+
}
147+
148+
async fn send_payjoin_post_request(
149+
&self,
150+
req: payjoin::Request,
151+
) -> reqwest::Result<reqwest::Response> {
152+
let client = reqwest::Client::new();
153+
client
154+
.post(req.url)
155+
.header("Content-Type", req.content_type)
156+
.body(req.body)
157+
.send()
158+
.await
159+
}
160+
}

0 commit comments

Comments
 (0)