Skip to content

Commit 00a9783

Browse files
authored
Add directory fallback behavior (#1695)
2 parents d25ab88 + f5906fe commit 00a9783

7 files changed

Lines changed: 112 additions & 51 deletions

File tree

payjoin-cli/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ rpchost = "http://localhost:18443/wallet/sender"
7777

7878
# For v2, our config also requires a payjoin directory server and OHTTP relay
7979
[v2]
80-
pj_directory = "https://payjo.in"
80+
pj_directories = ["https://payjo.in", "https://lets.payjo.in"]
8181
ohttp_relays = ["https://pj.benalleng.com", "https://pj.bobspacebkk.com", "https://payjoin.achow101.com"]
8282
```
8383

@@ -92,7 +92,7 @@ rpchost = "http://localhost:18443/wallet/receiver"
9292

9393
# For v2, our config also requires a payjoin directory server and OHTTP relay
9494
[v2]
95-
pj_directory = "https://payjo.in"
95+
pj_directories = ["https://payjo.in", "https://lets.payjo.in"]
9696
ohttp_relays = ["https://pj.benalleng.com", "https://pj.bobspacebkk.com", "https://payjoin.achow101.com"]
9797
```
9898

payjoin-cli/example.config.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ rpcpassword = "password"
4848

4949
# Version 2 Configuration
5050
# [v2]
51-
# pj_directory = "https://payjo.in"
51+
# pj_directories = ["https://payjo.in", "https://lets.payjo.in"]
5252
# ohttp_relays = ["https://pj.benalleng.com", "https://pj.bobspacebkk.com", "https://payjoin.achow101.com", "https://example.com"]
5353
# # Optional: The HPKE keys which need to be fetched ahead of time from the pj_endpoint
5454
# # for the payjoin packets to be encrypted.

payjoin-cli/src/app/config.rs

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ pub struct V2Config {
3535
#[serde(deserialize_with = "deserialize_ohttp_keys_from_path")]
3636
pub ohttp_keys: Option<payjoin::OhttpKeys>,
3737
pub ohttp_relays: Vec<Url>,
38-
pub pj_directory: Url,
38+
pub pj_directories: Vec<Url>,
3939
}
4040

4141
#[allow(clippy::large_enum_variant)]
@@ -207,6 +207,11 @@ impl Config {
207207
"Only one OHTTP relay is configured. Add more ohttp_relays to improve privacy."
208208
);
209209
}
210+
if v2.pj_directories.len() < 2 {
211+
tracing::warn!(
212+
"Only one payjoin directory is configured. Add more pj_directories to enable fallback."
213+
);
214+
}
210215
config.version = Some(VersionConfig::V2(v2))
211216
}
212217
Err(e) => {
@@ -308,19 +313,22 @@ fn add_v1_defaults(config: Builder, cli: &Cli) -> Result<Builder, ConfigError> {
308313
fn add_v2_defaults(config: Builder, cli: &Cli) -> Result<Builder, ConfigError> {
309314
// Set default values
310315
let config = config
311-
.set_default("v2.pj_directory", "https://payjo.in")?
316+
.set_default("v2.pj_directories", vec!["https://payjo.in", "https://lets.payjo.in"])?
312317
.set_default("v2.ohttp_keys", None::<String>)?;
313318

314319
// Override config values with command line arguments if applicable
315-
let pj_directory = cli.pj_directory.as_ref().map(|s| s.as_str());
320+
let pj_directories = cli
321+
.pj_directories
322+
.as_ref()
323+
.map(|urls| urls.iter().map(|url| url.as_str()).collect::<Vec<_>>());
316324
let ohttp_keys = cli.ohttp_keys.as_ref().map(|p| p.to_string_lossy().into_owned());
317325
let ohttp_relays = cli
318326
.ohttp_relays
319327
.as_ref()
320328
.map(|urls| urls.iter().map(|url| url.as_str()).collect::<Vec<_>>());
321329

322330
config
323-
.set_override_option("v2.pj_directory", pj_directory)?
331+
.set_override_option("v2.pj_directories", pj_directories)?
324332
.set_override_option("v2.ohttp_keys", ohttp_keys)?
325333
.set_override_option("v2.ohttp_relays", ohttp_relays)
326334
}
@@ -347,7 +355,7 @@ fn handle_subcommands(config: Builder, cli: &Cli) -> Result<Builder, ConfigError
347355
#[cfg(feature = "v1")]
348356
pj_endpoint,
349357
#[cfg(feature = "v2")]
350-
pj_directory,
358+
pj_directories,
351359
#[cfg(feature = "v2")]
352360
ohttp_keys,
353361
..
@@ -362,8 +370,10 @@ fn handle_subcommands(config: Builder, cli: &Cli) -> Result<Builder, ConfigError
362370
#[cfg(feature = "v2")]
363371
let config = config
364372
.set_override_option(
365-
"v2.pj_directory",
366-
pj_directory.clone().map(|s| s.to_string()),
373+
"v2.pj_directories",
374+
pj_directories
375+
.as_ref()
376+
.map(|urls| urls.iter().map(|url| url.as_str()).collect::<Vec<_>>()),
367377
)?
368378
.set_override_option(
369379
"v2.ohttp_keys",

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

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ use tokio::sync::watch;
2323
use super::config::Config;
2424
use super::wallet::BitcoindWallet;
2525
use super::App as AppTrait;
26-
use crate::app::v2::ohttp::RelayManager;
26+
use crate::app::v2::ohttp::MailroomManager;
2727
use crate::app::{handle_interrupt, http_agent};
2828
use crate::cli::Role as CliRole;
2929
use crate::db::v2::{ReceiverPersister, SenderPersister, SessionId};
@@ -42,7 +42,7 @@ pub(crate) struct App {
4242
db: Arc<Database>,
4343
wallet: BitcoindWallet,
4444
interrupt: watch::Receiver<()>,
45-
relay_manager: RelayManager,
45+
mailroom_manager: MailroomManager,
4646
}
4747

4848
trait StatusText {
@@ -142,11 +142,11 @@ impl<Status: StatusText> fmt::Display for SessionHistoryRow<Status> {
142142
impl AppTrait for App {
143143
async fn new(config: Config) -> Result<Self> {
144144
let db = Arc::new(Database::create(&config.db_path)?);
145-
let relay_manager = RelayManager::new(config.clone());
145+
let mailroom_manager = MailroomManager::new(config.clone());
146146
let (interrupt_tx, interrupt_rx) = watch::channel(());
147147
tokio::spawn(handle_interrupt(interrupt_tx));
148148
let wallet = BitcoindWallet::new(&config.bitcoind).await?;
149-
let app = Self { config, db, wallet, interrupt: interrupt_rx, relay_manager };
149+
let app = Self { config, db, wallet, interrupt: interrupt_rx, mailroom_manager };
150150
app.wallet()
151151
.network()
152152
.context("Failed to connect to bitcoind. Check config RPC connection.")?;
@@ -278,11 +278,25 @@ impl AppTrait for App {
278278

279279
async fn receive_payjoin(&self, amount: Amount) -> Result<()> {
280280
let address = self.wallet().get_new_address()?;
281-
let ohttp_keys = self.relay_manager.unwrap_ohttp_keys_or_else_fetch().await?.ohttp_keys;
282281
let persister = ReceiverPersister::new(self.db.clone())?;
282+
let (directory, ohttp_keys) = loop {
283+
let directory = self.mailroom_manager.choose_directory()?;
284+
match self
285+
.mailroom_manager
286+
.unwrap_ohttp_keys_or_else_fetch_from_directory(&directory)
287+
.await
288+
{
289+
Ok(keys) => break (directory, keys.ohttp_keys),
290+
Err(e) => {
291+
tracing::debug!("Directory {directory} failed: {e:#}");
292+
self.mailroom_manager.add_failed_directory(directory);
293+
self.mailroom_manager.clear_failed_relays();
294+
continue;
295+
}
296+
}
297+
};
283298
let mut receiver_builder =
284-
ReceiverBuilder::new(address, self.config.v2()?.pj_directory.as_str(), ohttp_keys)?
285-
.with_amount(amount);
299+
ReceiverBuilder::new(address, directory.as_str(), ohttp_keys)?.with_amount(amount);
286300
if let Some(max_fee_rate) = self.config.max_fee_rate {
287301
receiver_builder = receiver_builder.with_max_fee_rate(max_fee_rate);
288302
}
@@ -1066,13 +1080,13 @@ impl App {
10661080
E: Into<anyhow::Error>,
10671081
{
10681082
loop {
1069-
let relay = self.relay_manager.choose_relay()?;
1083+
let relay = self.mailroom_manager.choose_relay()?;
10701084
let (req, ctx) = build(relay.as_str()).map_err(Into::into)?;
10711085
match self.post_request(req).await {
10721086
Ok(resp) => return Ok((resp, ctx)),
10731087
Err(e) => {
10741088
tracing::debug!("Request to relay {relay} failed: {e:?}");
1075-
self.relay_manager.add_failed_relay(relay);
1089+
self.mailroom_manager.add_failed_relay(relay);
10761090
}
10771091
}
10781092
}

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

Lines changed: 60 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
1-
//! OHTTP relay selection and key bootstrapping for the payjoin-cli.
1+
//! OHTTP relay and payjoin directory selection / key bootstrapping for the payjoin-cli.
22
//!
3-
//! [`RelayManager`] tracks relays that have failed, excluding them from
4-
//! future selections for the lifetime of the [`RelayManager`].
3+
//! [`MailroomManager`] tracks relays and directories that have failed,
4+
//! excluding them from future selections for the lifetime of the [`MailroomManager`].
55
//!
6-
//! `unwrap_ohttp_keys_or_else_fetch` returns user-supplied keys when present,
7-
//! otherwise selects a relay at random from the configured list,
8-
//! excluding relays that [`RelayManager`] has marked as failed,
9-
//! to avoid a fixed contact pattern at the network layer.
6+
//! `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.
1014
use std::sync::{Arc, Mutex};
1115

1216
use anyhow::{anyhow, Result};
@@ -15,20 +19,33 @@ use payjoin::Url;
1519
use super::Config;
1620

1721
#[derive(Debug, Clone)]
18-
pub struct RelayManager {
22+
pub struct MailroomManager {
1923
config: Config,
2024
failed_relays: Arc<Mutex<Vec<Url>>>,
25+
failed_directories: Arc<Mutex<Vec<Url>>>,
2126
}
2227

23-
impl RelayManager {
28+
impl MailroomManager {
2429
pub fn new(config: Config) -> Self {
25-
RelayManager { config, failed_relays: Arc::new(Mutex::new(Vec::new())) }
30+
MailroomManager {
31+
config,
32+
failed_relays: Arc::new(Mutex::new(Vec::new())),
33+
failed_directories: Arc::new(Mutex::new(Vec::new())),
34+
}
2635
}
2736

2837
pub fn add_failed_relay(&self, relay: Url) {
2938
self.failed_relays.lock().expect("Lock should not be poisoned").push(relay);
3039
}
3140

41+
pub fn clear_failed_relays(&self) {
42+
self.failed_relays.lock().expect("Lock should not be poisoned").clear();
43+
}
44+
45+
pub fn add_failed_directory(&self, directory: Url) {
46+
self.failed_directories.lock().expect("Lock should not be poisoned").push(directory);
47+
}
48+
3249
pub fn choose_relay(&self) -> Result<Url> {
3350
use payjoin::bitcoin::secp256k1::rand::prelude::SliceRandom;
3451
let relays = &self.config.v2()?.ohttp_relays;
@@ -46,16 +63,35 @@ impl RelayManager {
4663
.ok_or_else(|| anyhow!("Failed to select from remaining relays"))
4764
}
4865

49-
pub(crate) async fn unwrap_ohttp_keys_or_else_fetch(&self) -> Result<ValidatedOhttpKeys> {
66+
pub fn choose_directory(&self) -> Result<Url> {
67+
use payjoin::bitcoin::secp256k1::rand::prelude::SliceRandom;
68+
let directories = &self.config.v2()?.pj_directories;
69+
let failed_directories =
70+
self.failed_directories.lock().expect("Lock should not be poisoned");
71+
let remaining_directories: Vec<_> =
72+
directories.iter().filter(|d| !failed_directories.contains(d)).cloned().collect();
73+
74+
if remaining_directories.is_empty() {
75+
return Err(anyhow!("No valid directories available"));
76+
}
77+
78+
remaining_directories
79+
.choose(&mut payjoin::bitcoin::key::rand::thread_rng())
80+
.cloned()
81+
.ok_or_else(|| anyhow!("Failed to select from remaining directories"))
82+
}
83+
84+
pub(crate) async fn unwrap_ohttp_keys_or_else_fetch_from_directory(
85+
&self,
86+
directory: &Url,
87+
) -> Result<ValidatedOhttpKeys> {
5088
if let Some(ohttp_keys) = self.config.v2()?.ohttp_keys.clone() {
5189
return Ok(ValidatedOhttpKeys { ohttp_keys });
5290
}
53-
self.fetch_ohttp_keys().await
91+
self.fetch_ohttp_keys_from_directory(directory).await
5492
}
5593

56-
async fn fetch_ohttp_keys(&self) -> Result<ValidatedOhttpKeys> {
57-
let payjoin_directory = &self.config.v2()?.pj_directory;
58-
94+
async fn fetch_ohttp_keys_from_directory(&self, directory: &Url) -> Result<ValidatedOhttpKeys> {
5995
loop {
6096
let selected_relay = self.choose_relay()?;
6197

@@ -66,27 +102,27 @@ impl RelayManager {
66102
let cert_der = std::fs::read(cert_path)?;
67103
payjoin::io::fetch_ohttp_keys_with_cert(
68104
selected_relay.as_str(),
69-
payjoin_directory.as_str(),
105+
directory.as_str(),
70106
&cert_der,
71107
)
72108
.await
73109
} else {
74-
payjoin::io::fetch_ohttp_keys(
75-
selected_relay.as_str(),
76-
payjoin_directory.as_str(),
77-
)
78-
.await
110+
payjoin::io::fetch_ohttp_keys(selected_relay.as_str(), directory.as_str())
111+
.await
79112
}
80113
}
81114
#[cfg(not(feature = "_manual-tls"))]
82-
payjoin::io::fetch_ohttp_keys(selected_relay.as_str(), payjoin_directory.as_str())
83-
.await
115+
payjoin::io::fetch_ohttp_keys(selected_relay.as_str(), directory.as_str()).await
84116
};
85117

86118
match ohttp_keys {
87119
Ok(keys) => return Ok(ValidatedOhttpKeys { ohttp_keys: keys }),
88120
Err(payjoin::io::Error::UnexpectedStatusCode(e)) => {
89-
return Err(payjoin::io::Error::UnexpectedStatusCode(e).into());
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}"));
90126
}
91127
Err(e) => {
92128
tracing::debug!("Failed to connect to relay: {selected_relay}, {e:?}");

payjoin-cli/src/cli/mod.rs

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,8 @@ pub struct Cli {
7272
pub ohttp_keys: Option<PathBuf>,
7373

7474
#[cfg(feature = "v2")]
75-
#[arg(long = "pj-directory", help = "The directory to store payjoin requests", value_parser = value_parser!(Url))]
76-
pub pj_directory: Option<Url>,
75+
#[arg(long = "pj-directories", help = "One or more payjoin directory URLs, comma-separated", value_parser = value_parser!(Url), value_delimiter = ',', action = clap::ArgAction::Append)]
76+
pub pj_directories: Option<Vec<Url>>,
7777

7878
#[cfg(feature = "_manual-tls")]
7979
#[arg(long = "root-certificate", help = "Specify a TLS certificate to be added as a root", value_parser = value_parser!(PathBuf))]
@@ -117,9 +117,9 @@ pub enum Commands {
117117
pj_endpoint: Option<Box<Url>>,
118118

119119
#[cfg(feature = "v2")]
120-
/// The directory to store payjoin requests
121-
#[arg(long = "pj-directory", value_parser = parse_boxed_url)]
122-
pj_directory: Option<Box<Url>>,
120+
/// One or more payjoin directory URLs, comma-separated
121+
#[arg(long = "pj-directories", value_parser = value_parser!(Url), value_delimiter = ',', action = clap::ArgAction::Append)]
122+
pj_directories: Option<Vec<Url>>,
123123

124124
#[cfg(feature = "v2")]
125125
/// The path to the ohttp keys file
@@ -165,6 +165,7 @@ pub fn parse_fee_rate_in_sat_per_vb(s: &str) -> Result<FeeRate, std::num::ParseF
165165
Ok(FeeRate::from_sat_per_kwu(fee_rate_sat_per_kwu.ceil() as u64))
166166
}
167167

168+
#[cfg(feature = "v1")]
168169
fn parse_boxed_url(s: &str) -> Result<Box<Url>, String> {
169170
s.parse::<Url>().map(Box::new).map_err(|e| e.to_string())
170171
}

payjoin-cli/tests/e2e.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -285,7 +285,7 @@ mod e2e {
285285
.arg(ohttp_relays)
286286
.arg("receive")
287287
.arg(RECEIVE_SATS)
288-
.arg("--pj-directory")
288+
.arg("--pj-directories")
289289
.arg(directory)
290290
.arg("--ohttp-keys")
291291
.arg(&ohttp_keys_path)
@@ -728,7 +728,7 @@ mod e2e {
728728
.arg(ohttp_relays)
729729
.arg("receive")
730730
.arg(RECEIVE_SATS)
731-
.arg("--pj-directory")
731+
.arg("--pj-directories")
732732
.arg(directory)
733733
.arg("--ohttp-keys")
734734
.arg(&ohttp_keys_path)
@@ -903,7 +903,7 @@ mod e2e {
903903
.arg(ohttp_relays)
904904
.arg("receive")
905905
.arg(RECEIVE_SATS)
906-
.arg("--pj-directory")
906+
.arg("--pj-directories")
907907
.arg(directory)
908908
.arg("--ohttp-keys")
909909
.arg(&ohttp_keys_path)

0 commit comments

Comments
 (0)