From f5906fe865ba2df5a723fd8ddd4fad9da78b648e Mon Sep 17 00:00:00 2001 From: Benalleng Date: Thu, 25 Jun 2026 14:27:02 -0400 Subject: [PATCH] Add directory fallback behavior This adds the ability for a backup directory to be chosen by the receiver incase of a failure on the ohttp key fetch. As well now that there is Relay and directory management the Struct is better suited as MailroomManager. Co-authored-by: Oladapo Oyindamola <111582215+0xZaddyy@users.noreply.github.com> --- payjoin-cli/README.md | 4 +- payjoin-cli/example.config.toml | 2 +- payjoin-cli/src/app/config.rs | 24 +++++++--- payjoin-cli/src/app/v2/mod.rs | 32 +++++++++---- payjoin-cli/src/app/v2/ohttp.rs | 84 +++++++++++++++++++++++---------- payjoin-cli/src/cli/mod.rs | 11 +++-- payjoin-cli/tests/e2e.rs | 6 +-- 7 files changed, 112 insertions(+), 51 deletions(-) diff --git a/payjoin-cli/README.md b/payjoin-cli/README.md index 95cb90332..3b7ae5cb5 100644 --- a/payjoin-cli/README.md +++ b/payjoin-cli/README.md @@ -77,7 +77,7 @@ rpchost = "http://localhost:18443/wallet/sender" # For v2, our config also requires a payjoin directory server and OHTTP relay [v2] -pj_directory = "https://payjo.in" +pj_directories = ["https://payjo.in", "https://lets.payjo.in"] ohttp_relays = ["https://pj.benalleng.com", "https://pj.bobspacebkk.com", "https://payjoin.achow101.com"] ``` @@ -92,7 +92,7 @@ rpchost = "http://localhost:18443/wallet/receiver" # For v2, our config also requires a payjoin directory server and OHTTP relay [v2] -pj_directory = "https://payjo.in" +pj_directories = ["https://payjo.in", "https://lets.payjo.in"] ohttp_relays = ["https://pj.benalleng.com", "https://pj.bobspacebkk.com", "https://payjoin.achow101.com"] ``` diff --git a/payjoin-cli/example.config.toml b/payjoin-cli/example.config.toml index e68f41eb0..af1d7f094 100644 --- a/payjoin-cli/example.config.toml +++ b/payjoin-cli/example.config.toml @@ -48,7 +48,7 @@ rpcpassword = "password" # Version 2 Configuration # [v2] -# pj_directory = "https://payjo.in" +# pj_directories = ["https://payjo.in", "https://lets.payjo.in"] # ohttp_relays = ["https://pj.benalleng.com", "https://pj.bobspacebkk.com", "https://payjoin.achow101.com", "https://example.com"] # # Optional: The HPKE keys which need to be fetched ahead of time from the pj_endpoint # # for the payjoin packets to be encrypted. diff --git a/payjoin-cli/src/app/config.rs b/payjoin-cli/src/app/config.rs index 04b2c2cfc..74ee00995 100644 --- a/payjoin-cli/src/app/config.rs +++ b/payjoin-cli/src/app/config.rs @@ -35,7 +35,7 @@ pub struct V2Config { #[serde(deserialize_with = "deserialize_ohttp_keys_from_path")] pub ohttp_keys: Option, pub ohttp_relays: Vec, - pub pj_directory: Url, + pub pj_directories: Vec, } #[allow(clippy::large_enum_variant)] @@ -207,6 +207,11 @@ impl Config { "Only one OHTTP relay is configured. Add more ohttp_relays to improve privacy." ); } + if v2.pj_directories.len() < 2 { + tracing::warn!( + "Only one payjoin directory is configured. Add more pj_directories to enable fallback." + ); + } config.version = Some(VersionConfig::V2(v2)) } Err(e) => { @@ -308,11 +313,14 @@ fn add_v1_defaults(config: Builder, cli: &Cli) -> Result { fn add_v2_defaults(config: Builder, cli: &Cli) -> Result { // Set default values let config = config - .set_default("v2.pj_directory", "https://payjo.in")? + .set_default("v2.pj_directories", vec!["https://payjo.in", "https://lets.payjo.in"])? .set_default("v2.ohttp_keys", None::)?; // Override config values with command line arguments if applicable - let pj_directory = cli.pj_directory.as_ref().map(|s| s.as_str()); + let pj_directories = cli + .pj_directories + .as_ref() + .map(|urls| urls.iter().map(|url| url.as_str()).collect::>()); let ohttp_keys = cli.ohttp_keys.as_ref().map(|p| p.to_string_lossy().into_owned()); let ohttp_relays = cli .ohttp_relays @@ -320,7 +328,7 @@ fn add_v2_defaults(config: Builder, cli: &Cli) -> Result { .map(|urls| urls.iter().map(|url| url.as_str()).collect::>()); config - .set_override_option("v2.pj_directory", pj_directory)? + .set_override_option("v2.pj_directories", pj_directories)? .set_override_option("v2.ohttp_keys", ohttp_keys)? .set_override_option("v2.ohttp_relays", ohttp_relays) } @@ -347,7 +355,7 @@ fn handle_subcommands(config: Builder, cli: &Cli) -> Result Result>()), )? .set_override_option( "v2.ohttp_keys", diff --git a/payjoin-cli/src/app/v2/mod.rs b/payjoin-cli/src/app/v2/mod.rs index cd0cf38ee..8223aa982 100644 --- a/payjoin-cli/src/app/v2/mod.rs +++ b/payjoin-cli/src/app/v2/mod.rs @@ -23,7 +23,7 @@ use tokio::sync::watch; use super::config::Config; use super::wallet::BitcoindWallet; use super::App as AppTrait; -use crate::app::v2::ohttp::RelayManager; +use crate::app::v2::ohttp::MailroomManager; use crate::app::{handle_interrupt, http_agent}; use crate::cli::Role as CliRole; use crate::db::v2::{ReceiverPersister, SenderPersister, SessionId}; @@ -42,7 +42,7 @@ pub(crate) struct App { db: Arc, wallet: BitcoindWallet, interrupt: watch::Receiver<()>, - relay_manager: RelayManager, + mailroom_manager: MailroomManager, } trait StatusText { @@ -142,11 +142,11 @@ impl fmt::Display for SessionHistoryRow { impl AppTrait for App { async fn new(config: Config) -> Result { let db = Arc::new(Database::create(&config.db_path)?); - let relay_manager = RelayManager::new(config.clone()); + let mailroom_manager = MailroomManager::new(config.clone()); let (interrupt_tx, interrupt_rx) = watch::channel(()); tokio::spawn(handle_interrupt(interrupt_tx)); let wallet = BitcoindWallet::new(&config.bitcoind).await?; - let app = Self { config, db, wallet, interrupt: interrupt_rx, relay_manager }; + let app = Self { config, db, wallet, interrupt: interrupt_rx, mailroom_manager }; app.wallet() .network() .context("Failed to connect to bitcoind. Check config RPC connection.")?; @@ -278,11 +278,25 @@ impl AppTrait for App { async fn receive_payjoin(&self, amount: Amount) -> Result<()> { let address = self.wallet().get_new_address()?; - let ohttp_keys = self.relay_manager.unwrap_ohttp_keys_or_else_fetch().await?.ohttp_keys; let persister = ReceiverPersister::new(self.db.clone())?; + let (directory, ohttp_keys) = loop { + let directory = self.mailroom_manager.choose_directory()?; + match self + .mailroom_manager + .unwrap_ohttp_keys_or_else_fetch_from_directory(&directory) + .await + { + Ok(keys) => break (directory, keys.ohttp_keys), + Err(e) => { + tracing::debug!("Directory {directory} failed: {e:#}"); + self.mailroom_manager.add_failed_directory(directory); + self.mailroom_manager.clear_failed_relays(); + continue; + } + } + }; let mut receiver_builder = - ReceiverBuilder::new(address, self.config.v2()?.pj_directory.as_str(), ohttp_keys)? - .with_amount(amount); + ReceiverBuilder::new(address, directory.as_str(), ohttp_keys)?.with_amount(amount); if let Some(max_fee_rate) = self.config.max_fee_rate { receiver_builder = receiver_builder.with_max_fee_rate(max_fee_rate); } @@ -1066,13 +1080,13 @@ impl App { E: Into, { loop { - let relay = self.relay_manager.choose_relay()?; + let relay = self.mailroom_manager.choose_relay()?; let (req, ctx) = build(relay.as_str()).map_err(Into::into)?; match self.post_request(req).await { Ok(resp) => return Ok((resp, ctx)), Err(e) => { tracing::debug!("Request to relay {relay} failed: {e:?}"); - self.relay_manager.add_failed_relay(relay); + self.mailroom_manager.add_failed_relay(relay); } } } diff --git a/payjoin-cli/src/app/v2/ohttp.rs b/payjoin-cli/src/app/v2/ohttp.rs index 8b02175dc..e438540ec 100644 --- a/payjoin-cli/src/app/v2/ohttp.rs +++ b/payjoin-cli/src/app/v2/ohttp.rs @@ -1,12 +1,16 @@ -//! OHTTP relay selection and key bootstrapping for the payjoin-cli. +//! OHTTP relay and payjoin directory selection / key bootstrapping for the payjoin-cli. //! -//! [`RelayManager`] tracks relays that have failed, excluding them from -//! future selections for the lifetime of the [`RelayManager`]. +//! [`MailroomManager`] tracks relays and directories that have failed, +//! excluding them from future selections for the lifetime of the [`MailroomManager`]. //! -//! `unwrap_ohttp_keys_or_else_fetch` returns user-supplied keys when present, -//! otherwise selects a relay at random from the configured list, -//! excluding relays that [`RelayManager`] has marked as failed, -//! to avoid a fixed contact pattern at the network layer. +//! `unwrap_ohttp_keys_or_else_fetch_from_directory` returns user-supplied keys +//! when present, otherwise selects a relay at random from the configured list +//! (excluding failed relays) to fetch OHTTP keys from the given directory. +//! +//! `fetch_ohttp_keys_from_directory` retries on relay failures (e.g. connection +//! errors) by selecting another relay. Once a directory is chosen for a session +//! it must not change — the directory is embedded in the BIP21 URI at session +//! creation and recovered from the session event log on resume. use std::sync::{Arc, Mutex}; use anyhow::{anyhow, Result}; @@ -15,20 +19,33 @@ use payjoin::Url; use super::Config; #[derive(Debug, Clone)] -pub struct RelayManager { +pub struct MailroomManager { config: Config, failed_relays: Arc>>, + failed_directories: Arc>>, } -impl RelayManager { +impl MailroomManager { pub fn new(config: Config) -> Self { - RelayManager { config, failed_relays: Arc::new(Mutex::new(Vec::new())) } + MailroomManager { + config, + failed_relays: Arc::new(Mutex::new(Vec::new())), + failed_directories: Arc::new(Mutex::new(Vec::new())), + } } pub fn add_failed_relay(&self, relay: Url) { self.failed_relays.lock().expect("Lock should not be poisoned").push(relay); } + pub fn clear_failed_relays(&self) { + self.failed_relays.lock().expect("Lock should not be poisoned").clear(); + } + + pub fn add_failed_directory(&self, directory: Url) { + self.failed_directories.lock().expect("Lock should not be poisoned").push(directory); + } + pub fn choose_relay(&self) -> Result { use payjoin::bitcoin::secp256k1::rand::prelude::SliceRandom; let relays = &self.config.v2()?.ohttp_relays; @@ -46,16 +63,35 @@ impl RelayManager { .ok_or_else(|| anyhow!("Failed to select from remaining relays")) } - pub(crate) async fn unwrap_ohttp_keys_or_else_fetch(&self) -> Result { + pub fn choose_directory(&self) -> Result { + use payjoin::bitcoin::secp256k1::rand::prelude::SliceRandom; + let directories = &self.config.v2()?.pj_directories; + let failed_directories = + self.failed_directories.lock().expect("Lock should not be poisoned"); + let remaining_directories: Vec<_> = + directories.iter().filter(|d| !failed_directories.contains(d)).cloned().collect(); + + if remaining_directories.is_empty() { + return Err(anyhow!("No valid directories available")); + } + + remaining_directories + .choose(&mut payjoin::bitcoin::key::rand::thread_rng()) + .cloned() + .ok_or_else(|| anyhow!("Failed to select from remaining directories")) + } + + pub(crate) async fn unwrap_ohttp_keys_or_else_fetch_from_directory( + &self, + directory: &Url, + ) -> Result { if let Some(ohttp_keys) = self.config.v2()?.ohttp_keys.clone() { return Ok(ValidatedOhttpKeys { ohttp_keys }); } - self.fetch_ohttp_keys().await + self.fetch_ohttp_keys_from_directory(directory).await } - async fn fetch_ohttp_keys(&self) -> Result { - let payjoin_directory = &self.config.v2()?.pj_directory; - + async fn fetch_ohttp_keys_from_directory(&self, directory: &Url) -> Result { loop { let selected_relay = self.choose_relay()?; @@ -66,27 +102,27 @@ impl RelayManager { let cert_der = std::fs::read(cert_path)?; payjoin::io::fetch_ohttp_keys_with_cert( selected_relay.as_str(), - payjoin_directory.as_str(), + directory.as_str(), &cert_der, ) .await } else { - payjoin::io::fetch_ohttp_keys( - selected_relay.as_str(), - payjoin_directory.as_str(), - ) - .await + payjoin::io::fetch_ohttp_keys(selected_relay.as_str(), directory.as_str()) + .await } } #[cfg(not(feature = "_manual-tls"))] - payjoin::io::fetch_ohttp_keys(selected_relay.as_str(), payjoin_directory.as_str()) - .await + payjoin::io::fetch_ohttp_keys(selected_relay.as_str(), directory.as_str()).await }; match ohttp_keys { Ok(keys) => return Ok(ValidatedOhttpKeys { ohttp_keys: keys }), Err(payjoin::io::Error::UnexpectedStatusCode(e)) => { - return Err(payjoin::io::Error::UnexpectedStatusCode(e).into()); + tracing::debug!( + "Directory {directory} returned unexpected status via relay {selected_relay}: {e:?}" + ); + self.add_failed_directory(directory.clone()); + return Err(anyhow!("Directory {directory} returned unexpected status: {e}")); } Err(e) => { tracing::debug!("Failed to connect to relay: {selected_relay}, {e:?}"); diff --git a/payjoin-cli/src/cli/mod.rs b/payjoin-cli/src/cli/mod.rs index 35c3aa78e..f04c5e8c6 100644 --- a/payjoin-cli/src/cli/mod.rs +++ b/payjoin-cli/src/cli/mod.rs @@ -72,8 +72,8 @@ pub struct Cli { pub ohttp_keys: Option, #[cfg(feature = "v2")] - #[arg(long = "pj-directory", help = "The directory to store payjoin requests", value_parser = value_parser!(Url))] - pub pj_directory: Option, + #[arg(long = "pj-directories", help = "One or more payjoin directory URLs, comma-separated", value_parser = value_parser!(Url), value_delimiter = ',', action = clap::ArgAction::Append)] + pub pj_directories: Option>, #[cfg(feature = "_manual-tls")] #[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 { pj_endpoint: Option>, #[cfg(feature = "v2")] - /// The directory to store payjoin requests - #[arg(long = "pj-directory", value_parser = parse_boxed_url)] - pj_directory: Option>, + /// One or more payjoin directory URLs, comma-separated + #[arg(long = "pj-directories", value_parser = value_parser!(Url), value_delimiter = ',', action = clap::ArgAction::Append)] + pj_directories: Option>, #[cfg(feature = "v2")] /// The path to the ohttp keys file @@ -165,6 +165,7 @@ pub fn parse_fee_rate_in_sat_per_vb(s: &str) -> Result Result, String> { s.parse::().map(Box::new).map_err(|e| e.to_string()) } diff --git a/payjoin-cli/tests/e2e.rs b/payjoin-cli/tests/e2e.rs index 3c5aa7477..8baff5928 100644 --- a/payjoin-cli/tests/e2e.rs +++ b/payjoin-cli/tests/e2e.rs @@ -285,7 +285,7 @@ mod e2e { .arg(ohttp_relays) .arg("receive") .arg(RECEIVE_SATS) - .arg("--pj-directory") + .arg("--pj-directories") .arg(directory) .arg("--ohttp-keys") .arg(&ohttp_keys_path) @@ -728,7 +728,7 @@ mod e2e { .arg(ohttp_relays) .arg("receive") .arg(RECEIVE_SATS) - .arg("--pj-directory") + .arg("--pj-directories") .arg(directory) .arg("--ohttp-keys") .arg(&ohttp_keys_path) @@ -903,7 +903,7 @@ mod e2e { .arg(ohttp_relays) .arg("receive") .arg(RECEIVE_SATS) - .arg("--pj-directory") + .arg("--pj-directories") .arg(directory) .arg("--ohttp-keys") .arg(&ohttp_keys_path)