Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions payjoin-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
```

Expand All @@ -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"]
```

Expand Down
2 changes: 1 addition & 1 deletion payjoin-cli/example.config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
24 changes: 17 additions & 7 deletions payjoin-cli/src/app/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ pub struct V2Config {
#[serde(deserialize_with = "deserialize_ohttp_keys_from_path")]
pub ohttp_keys: Option<payjoin::OhttpKeys>,
pub ohttp_relays: Vec<Url>,
pub pj_directory: Url,
pub pj_directories: Vec<Url>,
}

#[allow(clippy::large_enum_variant)]
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -308,19 +313,22 @@ fn add_v1_defaults(config: Builder, cli: &Cli) -> Result<Builder, ConfigError> {
fn add_v2_defaults(config: Builder, cli: &Cli) -> Result<Builder, ConfigError> {
// 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::<String>)?;

// 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::<Vec<_>>());
let ohttp_keys = cli.ohttp_keys.as_ref().map(|p| p.to_string_lossy().into_owned());
let ohttp_relays = cli
.ohttp_relays
.as_ref()
.map(|urls| urls.iter().map(|url| url.as_str()).collect::<Vec<_>>());

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)
}
Expand All @@ -347,7 +355,7 @@ fn handle_subcommands(config: Builder, cli: &Cli) -> Result<Builder, ConfigError
#[cfg(feature = "v1")]
pj_endpoint,
#[cfg(feature = "v2")]
pj_directory,
pj_directories,
#[cfg(feature = "v2")]
ohttp_keys,
..
Expand All @@ -362,8 +370,10 @@ fn handle_subcommands(config: Builder, cli: &Cli) -> Result<Builder, ConfigError
#[cfg(feature = "v2")]
let config = config
.set_override_option(
"v2.pj_directory",
pj_directory.clone().map(|s| s.to_string()),
"v2.pj_directories",
pj_directories
.as_ref()
.map(|urls| urls.iter().map(|url| url.as_str()).collect::<Vec<_>>()),
)?
.set_override_option(
"v2.ohttp_keys",
Expand Down
32 changes: 23 additions & 9 deletions payjoin-cli/src/app/v2/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -42,7 +42,7 @@ pub(crate) struct App {
db: Arc<Database>,
wallet: BitcoindWallet,
interrupt: watch::Receiver<()>,
relay_manager: RelayManager,
mailroom_manager: MailroomManager,
}

trait StatusText {
Expand Down Expand Up @@ -142,11 +142,11 @@ impl<Status: StatusText> fmt::Display for SessionHistoryRow<Status> {
impl AppTrait for App {
async fn new(config: Config) -> Result<Self> {
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.")?;
Expand Down Expand Up @@ -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;
}
}
};
Comment on lines +282 to +297

@DanGould DanGould Jun 30, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Really seems like a function deserving its own documentation. A good time to refactor this ~tech debt out would be #1035.

I had to check, but it looks like this will fail if all directories do when choose_directory()? throws, I think. Originally I was concerned this might loop forever, but it looks like it will close.

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);
}
Expand Down Expand Up @@ -1066,13 +1080,13 @@ impl App {
E: Into<anyhow::Error>,
{
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);
}
}
}
Expand Down
84 changes: 60 additions & 24 deletions payjoin-cli/src/app/v2/ohttp.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand All @@ -15,20 +19,33 @@ use payjoin::Url;
use super::Config;

#[derive(Debug, Clone)]
pub struct RelayManager {
pub struct MailroomManager {
config: Config,
failed_relays: Arc<Mutex<Vec<Url>>>,
failed_directories: Arc<Mutex<Vec<Url>>>,
}

impl RelayManager {
impl MailroomManager {
Comment thread
DanGould marked this conversation as resolved.
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<Url> {
use payjoin::bitcoin::secp256k1::rand::prelude::SliceRandom;
let relays = &self.config.v2()?.ohttp_relays;
Expand All @@ -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<ValidatedOhttpKeys> {
pub fn choose_directory(&self) -> Result<Url> {
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"))
}
Comment on lines +66 to +82

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NBD, I duplicate first as well, just noticing: could be DRY'd up with the choose_relay function


pub(crate) async fn unwrap_ohttp_keys_or_else_fetch_from_directory(
&self,
directory: &Url,
) -> Result<ValidatedOhttpKeys> {
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<ValidatedOhttpKeys> {
let payjoin_directory = &self.config.v2()?.pj_directory;

async fn fetch_ohttp_keys_from_directory(&self, directory: &Url) -> Result<ValidatedOhttpKeys> {
loop {
let selected_relay = self.choose_relay()?;

Expand All @@ -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:?}");
Expand Down
11 changes: 6 additions & 5 deletions payjoin-cli/src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,8 @@ pub struct Cli {
pub ohttp_keys: Option<PathBuf>,

#[cfg(feature = "v2")]
#[arg(long = "pj-directory", help = "The directory to store payjoin requests", value_parser = value_parser!(Url))]
pub pj_directory: Option<Url>,
#[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<Vec<Url>>,

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

#[cfg(feature = "v2")]
/// The directory to store payjoin requests
#[arg(long = "pj-directory", value_parser = parse_boxed_url)]
pj_directory: Option<Box<Url>>,
/// 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<Vec<Url>>,

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

#[cfg(feature = "v1")]
fn parse_boxed_url(s: &str) -> Result<Box<Url>, String> {
s.parse::<Url>().map(Box::new).map_err(|e| e.to_string())
}
6 changes: 3 additions & 3 deletions payjoin-cli/tests/e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
Loading