Skip to content

Commit 152393f

Browse files
committed
Create fallback ohttp-relay logic for payjoin-cli
The reliance on ohttp-relays can cause some distruption if they ever go offline but we did not have any examples of a fallback mechanism for choosing the relays at random and falling back to other relays in case they fail. This adds a new RelayState to manage which relays have already been tried and ulitmately hangs on to the first successful relay that it reaches.
1 parent 9349c7e commit 152393f

File tree

6 files changed

+197
-34
lines changed

6 files changed

+197
-34
lines changed

payjoin-cli/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ rpchost = "http://localhost:18443/wallet/sender"
7575
# For v2, our config also requires a payjoin directory server and OHTTP relay
7676
[v2]
7777
pj_directory = "https://payjo.in"
78-
ohttp_relay = "https://pj.bobspacebkk.com"
78+
ohttp_relays = ["https://pj.benalleng.com", "https://pj.bobspacebkk.com", "https://ohttp.achow101.com"]
7979
```
8080

8181
#### `receiver/config.toml`
@@ -89,7 +89,7 @@ rpchost = "http://localhost:18443/wallet/receiver"
8989
# For v2, our config also requires a payjoin directory server and OHTTP relay
9090
[v2]
9191
pj_directory = "https://payjo.in"
92-
ohttp_relay = "https://pj.bobspacebkk.com"
92+
ohttp_relays = ["https://pj.benalleng.com", "https://pj.bobspacebkk.com", "https://ohttp.achow101.com"]
9393
```
9494

9595
Now, the receiver must generate an address to receive the payment. The format is:

payjoin-cli/example.config.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ rpcpassword = "password"
4949
# Version 2 Configuration
5050
# [v2]
5151
# pj_directory = "https://payjo.in"
52-
# ohttp_relay = "https://pj.bobspacebkk.com"
52+
# ohttp_relays = ["https://pj.benalleng.com", "https://pj.bobspacebkk.com", "https://ohttp.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.
5555
# # These can now be fetched and no longer need to be configured.

payjoin-cli/src/app/config.rs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ pub struct V1Config {
3232
pub struct V2Config {
3333
#[serde(deserialize_with = "deserialize_ohttp_keys_from_path")]
3434
pub ohttp_keys: Option<payjoin::OhttpKeys>,
35-
pub ohttp_relay: Url,
35+
pub ohttp_relays: Vec<Url>,
3636
pub pj_directory: Url,
3737
}
3838

@@ -248,8 +248,10 @@ fn add_v1_defaults(builder: Builder) -> Result<Builder, ConfigError> {
248248
fn add_v2_defaults(builder: Builder, matches: &ArgMatches) -> Result<Builder, ConfigError> {
249249
builder
250250
.set_override_option(
251-
"v2.ohttp_relay",
252-
matches.get_one::<Url>("ohttp_relay").map(|s| s.as_str()),
251+
"v2.ohttp_relays",
252+
matches
253+
.get_many::<Url>("ohttp_relays")
254+
.map(|val| val.map(|s| s.as_str()).collect::<Vec<_>>()),
253255
)?
254256
.set_default("v2.pj_directory", "https://payjo.in")?
255257
.set_default("v2.ohttp_keys", None::<String>)

payjoin-cli/src/app/v2.rs

Lines changed: 180 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use std::sync::Arc;
1+
use std::sync::{Arc, Mutex};
22

33
use anyhow::{anyhow, Context, Result};
44
use payjoin::bitcoin::consensus::encode::serialize_hex;
@@ -17,22 +17,49 @@ use crate::app::{handle_interrupt, http_agent};
1717
use crate::db::v2::{ReceiverPersister, SenderPersister};
1818
use crate::db::Database;
1919

20+
#[derive(Debug, Clone)]
21+
pub struct RelayState {
22+
selected_relay: Option<payjoin::Url>,
23+
#[cfg(not(feature = "_danger-local-https"))]
24+
failed_relays: Vec<payjoin::Url>,
25+
}
26+
27+
impl RelayState {
28+
#[cfg(feature = "_danger-local-https")]
29+
pub fn new() -> Self { RelayState { selected_relay: None } }
30+
#[cfg(not(feature = "_danger-local-https"))]
31+
pub fn new() -> Self { RelayState { selected_relay: None, failed_relays: Vec::new() } }
32+
33+
#[cfg(not(feature = "_danger-local-https"))]
34+
pub fn set_selected_relay(&mut self, relay: payjoin::Url) { self.selected_relay = Some(relay); }
35+
36+
pub fn get_selected_relay(&self) -> Option<payjoin::Url> { self.selected_relay.clone() }
37+
38+
#[cfg(not(feature = "_danger-local-https"))]
39+
pub fn add_failed_relay(&mut self, relay: payjoin::Url) { self.failed_relays.push(relay); }
40+
41+
#[cfg(not(feature = "_danger-local-https"))]
42+
pub fn get_failed_relays(&self) -> Vec<payjoin::Url> { self.failed_relays.clone() }
43+
}
44+
2045
#[derive(Clone)]
2146
pub(crate) struct App {
2247
config: Config,
2348
db: Arc<Database>,
2449
wallet: BitcoindWallet,
2550
interrupt: watch::Receiver<()>,
51+
relay_state: Arc<Mutex<RelayState>>,
2652
}
2753

2854
#[async_trait::async_trait]
2955
impl AppTrait for App {
3056
fn new(config: Config) -> Result<Self> {
3157
let db = Arc::new(Database::create(&config.db_path)?);
58+
let relay_state = Arc::new(Mutex::new(RelayState::new()));
3259
let (interrupt_tx, interrupt_rx) = watch::channel(());
3360
tokio::spawn(handle_interrupt(interrupt_tx));
3461
let wallet = BitcoindWallet::new(&config.bitcoind)?;
35-
let app = Self { config, db, wallet, interrupt: interrupt_rx };
62+
let app = Self { config, db, wallet, interrupt: interrupt_rx, relay_state };
3663
app.wallet()
3764
.network()
3865
.context("Failed to connect to bitcoind. Check config RPC connection.")?;
@@ -69,7 +96,8 @@ impl AppTrait for App {
6996

7097
async fn receive_payjoin(&self, amount: Amount) -> Result<()> {
7198
let address = self.wallet().get_new_address()?;
72-
let ohttp_keys = unwrap_ohttp_keys_or_else_fetch(&self.config).await?;
99+
let ohttp_keys =
100+
unwrap_ohttp_keys_or_else_fetch(&self.config, self.relay_state.clone()).await?;
73101
let mut persister = ReceiverPersister::new(self.db.clone());
74102
let new_receiver = NewReceiver::new(
75103
address,
@@ -151,6 +179,8 @@ impl App {
151179
println!("Receive session established");
152180
let mut pj_uri = session.pj_uri();
153181
pj_uri.amount = amount;
182+
let ohttp_relay = self.unwrap_relay_or_else_fetch().await?;
183+
154184
println!("Request Payjoin by sharing this Payjoin Uri:");
155185
println!("{pj_uri}");
156186

@@ -168,14 +198,12 @@ impl App {
168198
let mut payjoin_proposal = match self.process_v2_proposal(receiver.clone()) {
169199
Ok(proposal) => proposal,
170200
Err(Error::ReplyToSender(e)) => {
171-
return Err(
172-
handle_recoverable_error(e, receiver, &self.config.v2()?.ohttp_relay).await
173-
);
201+
return Err(handle_recoverable_error(e, receiver, &ohttp_relay).await);
174202
}
175203
Err(e) => return Err(e.into()),
176204
};
177205
let (req, ohttp_ctx) = payjoin_proposal
178-
.extract_req(&self.config.v2()?.ohttp_relay)
206+
.extract_req(ohttp_relay)
179207
.map_err(|e| anyhow!("v2 req extraction failed {}", e))?;
180208
println!("Got a request from the sender. Responding with a Payjoin proposal.");
181209
let res = post_request(req).await?;
@@ -192,15 +220,16 @@ impl App {
192220
}
193221

194222
async fn long_poll_post(&self, req_ctx: &mut Sender) -> Result<Psbt> {
195-
match req_ctx.extract_v2(self.config.v2()?.ohttp_relay.clone()) {
223+
let ohttp_relay = self.unwrap_relay_or_else_fetch().await?;
224+
225+
match req_ctx.extract_v2(ohttp_relay.clone()) {
196226
Ok((req, ctx)) => {
197227
println!("Posting Original PSBT Payload request...");
198228
let response = post_request(req).await?;
199229
println!("Sent fallback transaction");
200230
let v2_ctx = Arc::new(ctx.process_response(&response.bytes().await?)?);
201231
loop {
202-
let (req, ohttp_ctx) =
203-
v2_ctx.extract_req(self.config.v2()?.ohttp_relay.clone())?;
232+
let (req, ohttp_ctx) = v2_ctx.extract_req(&ohttp_relay)?;
204233
let response = post_request(req).await?;
205234
match v2_ctx.process_response(&response.bytes().await?, ohttp_ctx) {
206235
Ok(Some(psbt)) => return Ok(psbt),
@@ -236,8 +265,10 @@ impl App {
236265
&self,
237266
session: &mut payjoin::receive::v2::Receiver,
238267
) -> Result<payjoin::receive::v2::UncheckedProposal> {
268+
let ohttp_relay = self.unwrap_relay_or_else_fetch().await?;
269+
239270
loop {
240-
let (req, context) = session.extract_req(&self.config.v2()?.ohttp_relay)?;
271+
let (req, context) = session.extract_req(&ohttp_relay)?;
241272
println!("Polling receive request...");
242273
let ohttp_response = post_request(req).await?;
243274
let proposal = session
@@ -289,6 +320,16 @@ impl App {
289320
log::debug!("Receiver's Payjoin proposal PSBT Rsponse: {payjoin_proposal_psbt:#?}");
290321
Ok(payjoin_proposal)
291322
}
323+
324+
async fn unwrap_relay_or_else_fetch(&self) -> Result<payjoin::Url> {
325+
let selected_relay =
326+
self.relay_state.lock().expect("Lock should not be poisoned").get_selected_relay();
327+
let ohttp_relay = match selected_relay {
328+
Some(relay) => relay,
329+
None => validate_relay(&self.config, self.relay_state.clone()).await?,
330+
};
331+
Ok(ohttp_relay)
332+
}
292333
}
293334

294335
/// Handle request error by sending an error response over the directory
@@ -335,26 +376,144 @@ fn try_contributing_inputs(
335376
.commit_inputs())
336377
}
337378

338-
async fn unwrap_ohttp_keys_or_else_fetch(config: &Config) -> Result<payjoin::OhttpKeys> {
379+
async fn unwrap_ohttp_keys_or_else_fetch(
380+
config: &Config,
381+
relay_state: Arc<Mutex<RelayState>>,
382+
) -> Result<payjoin::OhttpKeys> {
339383
if let Some(keys) = config.v2()?.ohttp_keys.clone() {
340384
println!("Using OHTTP Keys from config");
341385
Ok(keys)
342386
} else {
343387
println!("Bootstrapping private network transport over Oblivious HTTP");
344-
let ohttp_relay = config.v2()?.ohttp_relay.clone();
345-
let payjoin_directory = config.v2()?.pj_directory.clone();
346-
#[cfg(feature = "_danger-local-https")]
388+
389+
fetch_keys(config, relay_state.clone())
390+
.await
391+
.and_then(|keys| keys.ok_or_else(|| anyhow::anyhow!("No OHTTP keys found")))
392+
}
393+
}
394+
395+
#[cfg(not(feature = "_danger-local-https"))]
396+
async fn fetch_keys(
397+
config: &Config,
398+
relay_state: Arc<Mutex<RelayState>>,
399+
) -> Result<Option<payjoin::OhttpKeys>> {
400+
use payjoin::bitcoin::secp256k1::rand::prelude::SliceRandom;
401+
let payjoin_directory = config.v2()?.pj_directory.clone();
402+
let relays = config.v2()?.ohttp_relays.clone();
403+
404+
loop {
405+
let failed_relays =
406+
relay_state.lock().expect("Lock should not be poisoned").get_failed_relays();
407+
408+
let remaining_relays: Vec<_> =
409+
relays.iter().filter(|r| !failed_relays.contains(r)).cloned().collect();
410+
411+
if remaining_relays.is_empty() {
412+
return Err(anyhow!("No valid relays available"));
413+
}
414+
415+
let selected_relay =
416+
match remaining_relays.choose(&mut payjoin::bitcoin::key::rand::thread_rng()) {
417+
Some(relay) => relay.clone(),
418+
None => return Err(anyhow!("Failed to select from remaining relays")),
419+
};
420+
421+
relay_state
422+
.lock()
423+
.expect("Lock should not be poisoned")
424+
.set_selected_relay(selected_relay.clone());
425+
347426
let ohttp_keys = {
348-
let cert_der = crate::app::read_local_cert()?;
349-
payjoin::io::fetch_ohttp_keys_with_cert(ohttp_relay, payjoin_directory, cert_der)
350-
.await?
427+
payjoin::io::fetch_ohttp_keys(selected_relay.clone(), payjoin_directory.clone()).await
351428
};
352-
#[cfg(not(feature = "_danger-local-https"))]
353-
let ohttp_keys = payjoin::io::fetch_ohttp_keys(ohttp_relay, payjoin_directory).await?;
354-
Ok(ohttp_keys)
429+
430+
match ohttp_keys {
431+
Ok(keys) => return Ok(Some(keys)),
432+
Err(payjoin::io::Error::UnexpectedStatusCode(e)) => {
433+
return Err(payjoin::io::Error::UnexpectedStatusCode(e).into());
434+
}
435+
Err(e) => {
436+
log::debug!("Failed to connect to relay: {selected_relay}, {e:?}");
437+
relay_state
438+
.lock()
439+
.expect("Lock should not be poisoned")
440+
.add_failed_relay(selected_relay);
441+
}
442+
}
443+
}
444+
}
445+
446+
///Local relays are incapable of acting as proxies so we must opportunistically fetch keys from the config
447+
#[cfg(feature = "_danger-local-https")]
448+
async fn fetch_keys(
449+
config: &Config,
450+
_relay_state: Arc<Mutex<RelayState>>,
451+
) -> Result<Option<payjoin::OhttpKeys>> {
452+
let keys = config.v2()?.ohttp_keys.clone().expect("No OHTTP keys set");
453+
454+
Ok(Some(keys))
455+
}
456+
457+
#[cfg(not(feature = "_danger-local-https"))]
458+
async fn validate_relay(
459+
config: &Config,
460+
relay_state: Arc<Mutex<RelayState>>,
461+
) -> Result<payjoin::Url> {
462+
use payjoin::bitcoin::secp256k1::rand::prelude::SliceRandom;
463+
let payjoin_directory = config.v2()?.pj_directory.clone();
464+
let relays = config.v2()?.ohttp_relays.clone();
465+
466+
loop {
467+
let failed_relays =
468+
relay_state.lock().expect("Lock should not be poisoned").get_failed_relays();
469+
470+
let remaining_relays: Vec<_> =
471+
relays.iter().filter(|r| !failed_relays.contains(r)).cloned().collect();
472+
473+
if remaining_relays.is_empty() {
474+
return Err(anyhow!("No valid relays available"));
475+
}
476+
477+
let selected_relay =
478+
match remaining_relays.choose(&mut payjoin::bitcoin::key::rand::thread_rng()) {
479+
Some(relay) => relay.clone(),
480+
None => return Err(anyhow!("Failed to select from remaining relays")),
481+
};
482+
483+
relay_state
484+
.lock()
485+
.expect("Lock should not be poisoned")
486+
.set_selected_relay(selected_relay.clone());
487+
488+
let ohttp_keys =
489+
payjoin::io::fetch_ohttp_keys(selected_relay.clone(), payjoin_directory.clone()).await;
490+
491+
match ohttp_keys {
492+
Ok(_) => return Ok(selected_relay),
493+
Err(payjoin::io::Error::UnexpectedStatusCode(e)) => {
494+
return Err(payjoin::io::Error::UnexpectedStatusCode(e).into());
495+
}
496+
Err(e) => {
497+
log::debug!("Failed to connect to relay: {selected_relay}, {e:?}");
498+
relay_state
499+
.lock()
500+
.expect("Lock should not be poisoned")
501+
.add_failed_relay(selected_relay);
502+
}
503+
}
355504
}
356505
}
357506

507+
#[cfg(feature = "_danger-local-https")]
508+
async fn validate_relay(
509+
config: &Config,
510+
_relay_state: Arc<Mutex<RelayState>>,
511+
) -> Result<payjoin::Url> {
512+
let relay = config.v2()?.ohttp_relays.first().expect("no OHTTP relay set").clone();
513+
514+
Ok(relay)
515+
}
516+
358517
async fn post_request(req: payjoin::Request) -> Result<reqwest::Response> {
359518
let http = http_agent()?;
360519
http.post(req.url)

payjoin-cli/src/main.rs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -136,9 +136,11 @@ fn cli() -> ArgMatches {
136136
#[cfg(feature = "v2")]
137137
{
138138
cmd = cmd.arg(
139-
Arg::new("ohttp_relay")
140-
.long("ohttp-relay")
141-
.help("The ohttp relay url")
139+
Arg::new("ohttp_relays")
140+
.long("ohttp-relays")
141+
.help("One or more ohttp relay URLs, comma-separated")
142+
.action(clap::ArgAction::Append)
143+
.value_delimiter(',')
142144
.value_parser(value_parser!(Url)),
143145
);
144146
}

payjoin-cli/tests/e2e.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,7 @@ mod e2e {
210210
.arg(cookie_file)
211211
.arg("--db-path")
212212
.arg(&receiver_db_path)
213-
.arg("--ohttp-relay")
213+
.arg("--ohttp-relays")
214214
.arg(ohttp_relay)
215215
.arg("receive")
216216
.arg(RECEIVE_SATS)
@@ -230,7 +230,7 @@ mod e2e {
230230
.arg(cookie_file)
231231
.arg("--db-path")
232232
.arg(&sender_db_path)
233-
.arg("--ohttp-relay")
233+
.arg("--ohttp-relays")
234234
.arg(ohttp_relay)
235235
.arg("send")
236236
.arg(&bip21)
@@ -249,7 +249,7 @@ mod e2e {
249249
.arg(cookie_file)
250250
.arg("--db-path")
251251
.arg(&receiver_db_path)
252-
.arg("--ohttp-relay")
252+
.arg("--ohttp-relays")
253253
.arg(ohttp_relay)
254254
.arg("resume")
255255
.stdout(Stdio::piped())
@@ -265,7 +265,7 @@ mod e2e {
265265
.arg(cookie_file)
266266
.arg("--db-path")
267267
.arg(&sender_db_path)
268-
.arg("--ohttp-relay")
268+
.arg("--ohttp-relays")
269269
.arg(ohttp_relay)
270270
.arg("send")
271271
.arg(&bip21)

0 commit comments

Comments
 (0)