Skip to content

Commit 2f44534

Browse files
committed
Select the OHTTP relay inside fetch_ohttp_keys
Consumers inherit one selection and failover policy instead of each diverging.
1 parent 4693c34 commit 2f44534

5 files changed

Lines changed: 142 additions & 113 deletions

File tree

payjoin-cli/src/app/v2/mod.rs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ use payjoin::receive::v2::{
1212
ReceiverBuilder, SessionOutcome as ReceiverSessionOutcome, UncheckedOriginalPayload,
1313
WantsFeeRange, WantsInputs, WantsOutputs,
1414
};
15+
use payjoin::relay::RelaySelector;
1516
use payjoin::send::v2::{
1617
replay_event_log as replay_sender_event_log, PendingFallback as SenderPendingFallback,
1718
PollingForProposal, SendSession, Sender, SenderBuilder, SessionOutcome as SenderSessionOutcome,
@@ -278,7 +279,6 @@ impl AppTrait for App {
278279
Err(e) => {
279280
tracing::debug!("Directory {directory} failed: {e:#}");
280281
self.mailroom_manager.add_failed_directory(directory);
281-
self.mailroom_manager.clear_failed_relays();
282282
continue;
283283
}
284284
}
@@ -1055,14 +1055,17 @@ impl App {
10551055
F: FnMut(&str) -> std::result::Result<(payjoin::Request, T), E>,
10561056
E: Into<anyhow::Error>,
10571057
{
1058+
let mut selector = RelaySelector::new(self.config.v2()?.ohttp_relays.clone());
10581059
loop {
1059-
let relay = self.mailroom_manager.choose_relay()?;
1060+
let relay = selector
1061+
.select(&mut payjoin::bitcoin::key::rand::thread_rng())
1062+
.ok_or_else(|| anyhow!("No valid relays available"))?;
10601063
let (req, ctx) = build(relay.as_str()).map_err(Into::into)?;
10611064
match self.post_request(req).await {
10621065
Ok(resp) => return Ok((resp, ctx)),
10631066
Err(e) => {
10641067
tracing::debug!("Request to relay {relay} failed: {e:?}");
1065-
self.mailroom_manager.add_failed_relay(relay);
1068+
selector.mark_failed(&relay);
10661069
}
10671070
}
10681071
}

payjoin-cli/src/app/v2/ohttp.rs

Lines changed: 33 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,14 @@
1-
//! OHTTP relay and payjoin directory selection / key bootstrapping for the payjoin-cli.
1+
//! Payjoin directory selection / OHTTP key bootstrapping for the payjoin-cli.
22
//!
3-
//! [`MailroomManager`] tracks relays and directories that have failed,
4-
//! excluding them from future selections for the lifetime of the [`MailroomManager`].
3+
//! [`MailroomManager`] tracks directories that have failed, excluding them from
4+
//! future selections for the lifetime of the [`MailroomManager`]. Relay
5+
//! selection and failover are delegated to [`payjoin::io::fetch_ohttp_keys`].
56
//!
67
//! `unwrap_ohttp_keys_or_else_fetch_from_directory` returns user-supplied keys
7-
//! when present, otherwise selects a relay at random from the configured list
8-
//! (excluding failed relays) to fetch OHTTP keys from the given directory.
9-
//!
10-
//! `fetch_ohttp_keys_from_directory` retries on relay failures (e.g. connection
11-
//! errors) by selecting another relay. Once a directory is chosen for a session
12-
//! it must not change — the directory is embedded in the BIP21 URI at session
13-
//! creation and recovered from the session event log on resume.
8+
//! when present, otherwise fetches OHTTP keys from the given directory via a
9+
//! relay chosen from the configured list. Once a directory is chosen for a
10+
//! session it must not change — the directory is embedded in the BIP21 URI at
11+
//! session creation and recovered from the session event log on resume.
1412
use std::sync::{Arc, Mutex};
1513

1614
use anyhow::{anyhow, Result};
@@ -21,48 +19,18 @@ use super::Config;
2119
#[derive(Debug, Clone)]
2220
pub struct MailroomManager {
2321
config: Config,
24-
failed_relays: Arc<Mutex<Vec<Url>>>,
2522
failed_directories: Arc<Mutex<Vec<Url>>>,
2623
}
2724

2825
impl MailroomManager {
2926
pub fn new(config: Config) -> Self {
30-
MailroomManager {
31-
config,
32-
failed_relays: Arc::new(Mutex::new(Vec::new())),
33-
failed_directories: Arc::new(Mutex::new(Vec::new())),
34-
}
35-
}
36-
37-
pub fn add_failed_relay(&self, relay: Url) {
38-
self.failed_relays.lock().expect("Lock should not be poisoned").push(relay);
39-
}
40-
41-
pub fn clear_failed_relays(&self) {
42-
self.failed_relays.lock().expect("Lock should not be poisoned").clear();
27+
MailroomManager { config, failed_directories: Arc::new(Mutex::new(Vec::new())) }
4328
}
4429

4530
pub fn add_failed_directory(&self, directory: Url) {
4631
self.failed_directories.lock().expect("Lock should not be poisoned").push(directory);
4732
}
4833

49-
pub fn choose_relay(&self) -> Result<Url> {
50-
use payjoin::bitcoin::secp256k1::rand::prelude::SliceRandom;
51-
let relays = &self.config.v2()?.ohttp_relays;
52-
let failed_relays = self.failed_relays.lock().expect("Lock should not be poisoned");
53-
let remaining_relays: Vec<_> =
54-
relays.iter().filter(|r| !failed_relays.contains(r)).cloned().collect();
55-
56-
if remaining_relays.is_empty() {
57-
return Err(anyhow!("No valid relays available"));
58-
}
59-
60-
remaining_relays
61-
.choose(&mut payjoin::bitcoin::key::rand::thread_rng())
62-
.cloned()
63-
.ok_or_else(|| anyhow!("Failed to select from remaining relays"))
64-
}
65-
6634
pub fn choose_directory(&self) -> Result<Url> {
6735
use payjoin::bitcoin::secp256k1::rand::prelude::SliceRandom;
6836
let directories = &self.config.v2()?.pj_directories;
@@ -92,43 +60,33 @@ impl MailroomManager {
9260
}
9361

9462
async fn fetch_ohttp_keys_from_directory(&self, directory: &Url) -> Result<ValidatedOhttpKeys> {
95-
loop {
96-
let selected_relay = self.choose_relay()?;
97-
98-
let ohttp_keys = {
99-
#[cfg(feature = "_manual-tls")]
100-
{
101-
if let Some(cert_path) = self.config.root_certificate.as_ref() {
102-
let cert_der = std::fs::read(cert_path)?;
103-
payjoin::io::fetch_ohttp_keys_with_cert(
104-
selected_relay.as_str(),
105-
directory.as_str(),
106-
&cert_der,
107-
)
63+
let relays = &self.config.v2()?.ohttp_relays;
64+
let result = {
65+
#[cfg(feature = "_manual-tls")]
66+
{
67+
if let Some(cert_path) = self.config.root_certificate.as_ref() {
68+
let cert_der = std::fs::read(cert_path)?;
69+
payjoin::io::fetch_ohttp_keys_with_cert(relays, directory.as_str(), &cert_der)
10870
.await
109-
} else {
110-
payjoin::io::fetch_ohttp_keys(selected_relay.as_str(), directory.as_str())
111-
.await
112-
}
113-
}
114-
#[cfg(not(feature = "_manual-tls"))]
115-
payjoin::io::fetch_ohttp_keys(selected_relay.as_str(), directory.as_str()).await
116-
};
117-
118-
match ohttp_keys {
119-
Ok(keys) => return Ok(ValidatedOhttpKeys { ohttp_keys: keys }),
120-
Err(payjoin::io::Error::UnexpectedStatusCode(e)) => {
121-
tracing::debug!(
122-
"Directory {directory} returned unexpected status via relay {selected_relay}: {e:?}"
123-
);
124-
self.add_failed_directory(directory.clone());
125-
return Err(anyhow!("Directory {directory} returned unexpected status: {e}"));
126-
}
127-
Err(e) => {
128-
tracing::debug!("Failed to connect to relay: {selected_relay}, {e:?}");
129-
self.add_failed_relay(selected_relay);
71+
} else {
72+
payjoin::io::fetch_ohttp_keys(relays, directory.as_str()).await
13073
}
13174
}
75+
#[cfg(not(feature = "_manual-tls"))]
76+
payjoin::io::fetch_ohttp_keys(relays, directory.as_str()).await
77+
};
78+
79+
match result {
80+
Ok((ohttp_keys, _relay)) => Ok(ValidatedOhttpKeys { ohttp_keys }),
81+
Err(payjoin::io::Error::UnexpectedStatusCode(e)) => {
82+
tracing::debug!("Directory {directory} returned unexpected status: {e:?}");
83+
self.add_failed_directory(directory.clone());
84+
Err(anyhow!("Directory {directory} returned unexpected status: {e}"))
85+
}
86+
Err(e) => {
87+
tracing::debug!("Failed to fetch ohttp keys from directory {directory}: {e:?}");
88+
Err(anyhow!("Failed to fetch ohttp keys: {e}"))
89+
}
13290
}
13391
}
13492
}

payjoin-test-utils/src/v2.rs

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ use ohttp::hpke::{Aead, Kdf, Kem};
1010
use ohttp::{KeyId, SymmetricSuite};
1111
use payjoin::io::{fetch_ohttp_keys_with_cert, Error as IOError};
1212
pub use payjoin::persist::{InMemoryPersister, SessionPersister};
13-
use payjoin::OhttpKeys;
13+
use payjoin::{OhttpKeys, Url};
1414
use rcgen::Certificate;
1515
use reqwest::{Client, ClientBuilder};
1616
use rustls::pki_types::CertificateDer;
@@ -110,12 +110,16 @@ impl TestServices {
110110
}
111111

112112
pub async fn fetch_ohttp_keys(&self) -> Result<OhttpKeys, IOError> {
113-
fetch_ohttp_keys_with_cert(
114-
self.ohttp_relay_url().as_str(),
115-
self.directory_url().as_str(),
116-
&self.cert(),
117-
)
118-
.await
113+
let relays: Vec<Url> = self
114+
.ohttp_relays
115+
.iter()
116+
.map(|r| format!("http://localhost:{}", r.0))
117+
.map(|s| Url::parse(&s).expect("valid relay URL"))
118+
.collect();
119+
let (ohttp_keys, _relay) =
120+
fetch_ohttp_keys_with_cert(&relays, self.directory_url().as_str(), &self.cert())
121+
.await?;
122+
Ok(ohttp_keys)
119123
}
120124
}
121125

payjoin/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ Selected Improvements:
4444
- Document BIP77 v1 fallback behavior in `create_post_request` (#1593)
4545
- Remove redundant language from `finalize_proposal` rustdocs (#1567)
4646

47+
### Relay Selection
48+
49+
- Centralize OHTTP relay selection in `payjoin::relay::RelaySelector`; `io::fetch_ohttp_keys` now takes a relay slice and returns the relay that served the keys (breaking: `fetch_ohttp_keys(relay, dir) -> OhttpKeys` becomes `fetch_ohttp_keys(relays, dir) -> (OhttpKeys, Url)`)
50+
4751
## 0.25.0
4852

4953
Introduce monitoring typestates, replyable error handling, async

payjoin/src/core/io.rs

Lines changed: 88 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,60 +1,93 @@
11
//! IO-related types and functions. Specifically, fetching OHTTP keys from a payjoin directory.
22
use std::time::Duration;
33

4+
use bitcoin::secp256k1::rand;
45
use http::header::ACCEPT;
56
use reqwest::{Client, Proxy};
67

78
use crate::into_url::IntoUrl;
8-
use crate::OhttpKeys;
9+
use crate::relay::RelaySelector;
10+
use crate::{OhttpKeys, Url};
911

1012
/// Fetch the ohttp keys from the specified payjoin directory via proxy.
1113
///
12-
/// * `ohttp_relay`: The http CONNECT method proxy to request the ohttp keys from a payjoin
13-
/// directory. Proxying requests for ohttp keys ensures a client IP address is never revealed to
14-
/// the payjoin directory.
14+
/// * `relays`: The http CONNECT method proxies to request the ohttp keys from a payjoin
15+
/// directory, tried in random order until one succeeds. Proxying requests for ohttp keys
16+
/// ensures a client IP address is never revealed to the payjoin directory.
1517
///
1618
/// * `payjoin_directory`: The payjoin directory from which to fetch the ohttp keys. This
1719
/// directory stores and forwards payjoin client payloads.
20+
///
21+
/// Returns the ohttp keys and the relay that served them.
1822
pub async fn fetch_ohttp_keys(
19-
ohttp_relay: impl IntoUrl,
23+
relays: &[Url],
2024
payjoin_directory: impl IntoUrl,
21-
) -> Result<OhttpKeys, Error> {
22-
let ohttp_keys_url = payjoin_directory.into_url()?.join("/.well-known/ohttp-gateway")?;
23-
let proxy = Proxy::all(ohttp_relay.into_url()?.as_str())?;
24-
let client = Client::builder().proxy(proxy).http1_only().build()?;
25-
let res = client
26-
.get(ohttp_keys_url.as_str())
27-
.timeout(Duration::from_secs(10))
28-
.header(ACCEPT, "application/ohttp-keys")
29-
.send()
30-
.await?;
31-
parse_ohttp_keys_response(res).await
25+
) -> Result<(OhttpKeys, Url), Error> {
26+
fetch_ohttp_keys_inner(relays, payjoin_directory.into_url()?, None).await
3227
}
3328

3429
/// Fetch the ohttp keys from the specified payjoin directory via proxy.
3530
///
36-
/// * `ohttp_relay`: The http CONNECT method proxy to request the ohttp keys from a payjoin
37-
/// directory. Proxying requests for ohttp keys ensures a client IP address is never revealed to
38-
/// the payjoin directory.
31+
/// * `relays`: The http CONNECT method proxies to request the ohttp keys from a payjoin
32+
/// directory, tried in random order until one succeeds. Proxying requests for ohttp keys
33+
/// ensures a client IP address is never revealed to the payjoin directory.
3934
///
4035
/// * `payjoin_directory`: The payjoin directory from which to fetch the ohttp keys. This
4136
/// directory stores and forwards payjoin client payloads.
4237
///
4338
/// * `cert_der`: The DER-encoded certificate to use for local HTTPS connections.
39+
///
40+
/// Returns the ohttp keys and the relay that served them.
4441
#[cfg(feature = "_manual-tls")]
4542
pub async fn fetch_ohttp_keys_with_cert(
46-
ohttp_relay: impl IntoUrl,
43+
relays: &[Url],
4744
payjoin_directory: impl IntoUrl,
4845
cert_der: &[u8],
46+
) -> Result<(OhttpKeys, Url), Error> {
47+
fetch_ohttp_keys_inner(relays, payjoin_directory.into_url()?, Some(cert_der)).await
48+
}
49+
50+
async fn fetch_ohttp_keys_inner(
51+
relays: &[Url],
52+
payjoin_directory: Url,
53+
cert_der: Option<&[u8]>,
54+
) -> Result<(OhttpKeys, Url), Error> {
55+
let ohttp_keys_url = payjoin_directory.join("/.well-known/ohttp-gateway")?;
56+
let mut selector = RelaySelector::new(relays.to_vec());
57+
let mut last_err: Option<Error> = None;
58+
loop {
59+
let relay = match selector.select(&mut rand::thread_rng()) {
60+
Some(relay) => relay,
61+
None => return Err(last_err.unwrap_or(Error::NoRelaysAvailable)),
62+
};
63+
match fetch_keys_once(&relay, &ohttp_keys_url, cert_der).await {
64+
Ok(keys) => return Ok((keys, relay)),
65+
Err(e @ Error::UnexpectedStatusCode(_)) => return Err(e),
66+
Err(e) => {
67+
selector.mark_failed(&relay);
68+
last_err = Some(e);
69+
}
70+
}
71+
}
72+
}
73+
74+
async fn fetch_keys_once(
75+
relay: &Url,
76+
ohttp_keys_url: &Url,
77+
cert_der: Option<&[u8]>,
4978
) -> Result<OhttpKeys, Error> {
50-
let ohttp_keys_url = payjoin_directory.into_url()?.join("/.well-known/ohttp-gateway")?;
51-
let proxy = Proxy::all(ohttp_relay.into_url()?.as_str())?;
52-
let client = Client::builder()
53-
.use_rustls_tls()
54-
.add_root_certificate(reqwest::tls::Certificate::from_der(cert_der)?)
55-
.proxy(proxy)
56-
.http1_only()
57-
.build()?;
79+
#[cfg(not(feature = "_manual-tls"))]
80+
let _ = cert_der;
81+
let proxy = Proxy::all(relay.as_str())?;
82+
let builder = Client::builder().proxy(proxy).http1_only();
83+
#[cfg(feature = "_manual-tls")]
84+
let builder = match cert_der {
85+
Some(cert_der) => builder
86+
.use_rustls_tls()
87+
.add_root_certificate(reqwest::tls::Certificate::from_der(cert_der)?),
88+
None => builder,
89+
};
90+
let client = builder.build()?;
5891
let res = client
5992
.get(ohttp_keys_url.as_str())
6093
.timeout(Duration::from_secs(10))
@@ -80,6 +113,8 @@ async fn parse_ohttp_keys_response(res: reqwest::Response) -> Result<OhttpKeys,
80113
pub enum Error {
81114
/// When the payjoin directory returns an unexpected status code
82115
UnexpectedStatusCode(http::StatusCode),
116+
/// No relay was available to reach the payjoin directory.
117+
NoRelaysAvailable,
83118
/// Internal errors that should not be pattern matched by users
84119
#[doc(hidden)]
85120
Internal(InternalError),
@@ -126,6 +161,7 @@ impl std::fmt::Display for Error {
126161
Self::UnexpectedStatusCode(code) => {
127162
write!(f, "Unexpected status code from payjoin directory: {code}")
128163
}
164+
Self::NoRelaysAvailable => write!(f, "No relay was available to fetch ohttp keys"),
129165
Self::Internal(InternalError(e)) => e.fmt(f),
130166
}
131167
}
@@ -153,6 +189,7 @@ impl std::error::Error for Error {
153189
match self {
154190
Self::Internal(InternalError(e)) => e.source(),
155191
Self::UnexpectedStatusCode(_) => None,
192+
Self::NoRelaysAvailable => None,
156193
}
157194
}
158195
}
@@ -240,4 +277,27 @@ mod tests {
240277
"expected InvalidOhttpKeys error"
241278
);
242279
}
280+
281+
#[tokio::test]
282+
async fn fetch_with_no_relays_returns_no_relays_available() {
283+
assert!(matches!(
284+
fetch_ohttp_keys(&[], "https://directory.example").await,
285+
Err(Error::NoRelaysAvailable)
286+
));
287+
}
288+
289+
#[tokio::test]
290+
async fn fetch_exhausts_unreachable_relays_and_surfaces_last_error() {
291+
// Every relay fails to connect: the loop marks each failed and terminates
292+
// once none remain, surfacing the last transport error. NoRelaysAvailable
293+
// is reserved for an empty relay list, where no attempt was ever made.
294+
let relays = [
295+
Url::parse("http://127.0.0.1:1").expect("valid url"),
296+
Url::parse("http://127.0.0.1:2").expect("valid url"),
297+
];
298+
assert!(matches!(
299+
fetch_ohttp_keys(&relays, "https://directory.example").await,
300+
Err(Error::Internal(_))
301+
));
302+
}
243303
}

0 commit comments

Comments
 (0)