Skip to content

Split resume session relays#1399

Closed
benalleng wants to merge 3 commits into
payjoin:masterfrom
benalleng:split-resume-session-relays
Closed

Split resume session relays#1399
benalleng wants to merge 3 commits into
payjoin:masterfrom
benalleng:split-resume-session-relays

Conversation

@benalleng

@benalleng benalleng commented Mar 9, 2026

Copy link
Copy Markdown
Collaborator

Closes #1391

This commit ensures that each resumed payjoin-cli session is using a
separate instance of the RelayManager to then check the ohttp connection
independently. This fixes a bug where resuming would converge all
existing sessions to one ohttp relay.

Also, the incompatible_msrv allow seems not needed anymore?

Big pickle wrote most of this code. The free open weight models aren't too shabby now either. Though it did want to recreate the entire config instead of just making the app mutable. I thought this seemed a bit easier to parse as its clear everything else is the same.

Pull Request Checklist

Please confirm the following before requesting review:

@coveralls

coveralls commented Mar 9, 2026

Copy link
Copy Markdown
Collaborator

Coverage Report for CI Build 27162641746

Warning

Build has drifted: This PR's base is out of sync with its target branch, so coverage data may include unrelated changes.
Quick fix: rebase this PR. Learn more →

Coverage increased (+0.01%) to 85.369%

Details

  • Coverage increased (+0.01%) from the base build.
  • Patch coverage: 22 uncovered changes across 3 files (48 of 70 lines covered, 68.57%).
  • 504 coverage regressions across 17 files.

Uncovered Changes

File Changed Covered %
payjoin-cli/src/app/v2/mod.rs 48 31 64.58%
payjoin-cli/src/app/v2/ohttp.rs 17 13 76.47%
payjoin-cli/src/app/config.rs 5 4 80.0%

Coverage Regressions

504 previously-covered lines in 17 files lost coverage.

Top 10 Files by Coverage Loss Lines Losing Coverage Coverage
payjoin-cli/src/app/v2/mod.rs 161 52.59%
payjoin/src/core/receive/v2/mod.rs 119 91.34%
payjoin-mailroom/src/lib.rs 62 67.93%
payjoin/src/core/receive/v2/error.rs 27 45.45%
payjoin/src/core/persist.rs 24 96.46%
payjoin-cli/src/app/v1.rs 18 69.33%
payjoin/src/core/send/error.rs 17 40.29%
payjoin/src/core/send/v2/mod.rs 17 86.25%
payjoin/src/core/send/mod.rs 15 98.43%
payjoin/src/core/send/v2/error.rs 14 32.5%

Coverage Stats

Coverage Status
Relevant Lines: 14695
Covered Lines: 12545
Line Coverage: 85.37%
Coverage Strength: 370.93 hits per line

💛 - Coveralls

@benalleng benalleng requested a review from spacebear21 March 9, 2026 15:57

@arminsabouri arminsabouri left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

CAck cf8b0b7

big pickle? Cool.
Should we / do we already ensure that there is > 2 ohttp relays ?

@spacebear21

Copy link
Copy Markdown
Collaborator

This PR also includes the commit from #1390, is it meant to supersede that PR? I left a comment there that I think we should address holistically (i.e. is ohttp_keys "cache" config field relevant at all since we refetch every time anyways?)

@benalleng

Copy link
Copy Markdown
Collaborator Author

I should add blocked as this simply builds on top of #1390

This PR would have simply needed to also add the commit from #1390 as the ohttp_keys have been "cached" by that point so relay selection is skipped and resume would just choose ohttp_relay[0] no matter what. #1390 (comment)

@bc1cindy bc1cindy left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

tACK 5062488

minimal fix, each resumed session gets a fresh RelayManager, isolating relay state between sessions

but I think removing state from RelayManager and moving randomization into the struct as a method could simplify the design further, as commented in #1385 (comment)

@codaMW

codaMW commented Apr 12, 2026

Copy link
Copy Markdown
Contributor

Reviewed the fix, giving each resumed session a fresh RelayManager instance is a clean, minimal solution that correctly isolates relay state between sessions.

One open question from the discussion: @arminsabouri asked whether we should ensure there are >1 ohttp relays configured before attempting randomization. If ohttp_relays has only one entry, each session will always select the same relay regardless of this fix, the privacy benefit only kicks in with multiple relays. Is there a place we should surface a warning or enforce a minimum?

Also noting the 50% coverage on changed lines flagged by Coveralls, would a test asserting that two resumed sessions receive different relay instances be feasible here?

Comment thread payjoin-cli/src/app/v2/mod.rs Outdated
Comment on lines +297 to +298
let mut app = self_clone;
app.relay_manager = Arc::new(Mutex::new(RelayManager::new()));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This seems a bit off to me. Can add a getter to god app struct which randomizes which relay is use instead of cloning the app and mutating?

Maybe as simple as:

pub(crate) fn relay_manager (&self) -> RelayManager {
    RelayManager::new()
}

@arminsabouri

Copy link
Copy Markdown
Contributor

Reviewed the fix, giving each resumed session a fresh RelayManager instance is a clean, minimal solution that correctly isolates relay state between sessions.

One open question from the discussion: @arminsabouri asked whether we should ensure there are >1 ohttp relays configured before attempting randomization. If ohttp_relays has only one entry, each session will always select the same relay regardless of this fix, the privacy benefit only kicks in with multiple relays. Is there a place we should surface a warning or enforce a minimum?
Also noting the 50% coverage on changed lines flagged by Coveralls, would a test asserting that two resumed sessions receive different relay instances be feasible here?

Thanks for resurfacing that. Would a warning string be enough? Eitherway would like to get this PR through. This can be follow up work

@benalleng benalleng force-pushed the split-resume-session-relays branch from 5062488 to 582259e Compare April 13, 2026 15:20
@benalleng benalleng requested a review from arminsabouri April 13, 2026 15:29
&self,
sender: Sender<WithReplyKey>,
persister: &SenderPersister,
relay_manager: &mut RelayManager,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why do we need to pass a mut reference if we can get a relay manager from self.relay_manager() ?
Not requesting a change, just a question

@benalleng benalleng Apr 13, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

No you're right the current behavior would I think just return back to caching the relay when it finds one that works during any long polling No actually I think I am getting mixed up, I'm jumping ahead towards per-request logic. I don't think the logic fundamentally changes though I think its unnecessary to bubble it up to these calls instead of right before unwrap_ohttp_keys_or_else_fetch

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@benalleng per your last comment. Are you still making this change?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I think I misspoke earlier. the failures I was having the other day were due to the RelayManager::new() not being given a default to work without any context of the unwrap_ohttp_keys_or_else_fetch level the RelayManager state was falling back to the payjo.in default thus causing a failure in our nix checks

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Final followup, the mut reference is needed here as long as we are still using the same relay per session. This can be removed but really only once we move to per-request relay selection. Though I want to move towards that I think this is not the PR to do it yet.

@benalleng benalleng force-pushed the split-resume-session-relays branch 7 times, most recently from d06ba2e to a9dfc78 Compare April 14, 2026 13:48
@benalleng benalleng force-pushed the split-resume-session-relays branch 2 times, most recently from 4931cac to b86ecd4 Compare May 11, 2026 18:06
@benalleng benalleng force-pushed the split-resume-session-relays branch from b86ecd4 to db9a904 Compare June 1, 2026 17:53
@benalleng benalleng requested a review from arminsabouri June 1, 2026 18:17
Comment thread payjoin-cli/src/app/v2/ohttp.rs Outdated
Comment on lines +65 to +69
if relays.len() < 2 {
tracing::warn!(
"Only one OHTTP relay configured. Add more ohttp_relays to improve privacy."
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This belongs in config validation, not in fetch_ohttp_keys - otherwise it re-emits on every call (multiple sessions, retries).

@DanGould DanGould added this to the payjoin-cli-1.0 milestone Jun 2, 2026
@benalleng benalleng force-pushed the split-resume-session-relays branch from db9a904 to 07dd4da Compare June 2, 2026 19:08
benalleng added 2 commits June 3, 2026 11:58
This commit ensures that each resumed payjoin-cli session is using a
separate instance of the RelayManager to then check the ohttp connection
independently.  This fixes a bug where resuming would converge all
existing sessions to one ohttp relay.
@benalleng benalleng force-pushed the split-resume-session-relays branch from 07dd4da to 36e1f6b Compare June 3, 2026 15:58
@benalleng benalleng requested a review from va-an June 3, 2026 16:01

@va-an va-an left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

utACK 36e1f6b

@va-an

va-an commented Jun 6, 2026

Copy link
Copy Markdown

tACK 36e1f6b

Tested on this branch via payjoin-cli with the following config:

[v2]
pj_directory = "https://payjo.in"
ohttp_relays = [
    "https://pj.benalleng.com",
    "https://pj.bobspacebkk.com",
    "https://payjoin.achow101.com",
]

To isolate relay selection I locally patched the code to skip the bitcoind connection at startup and skip creating a receiver session in the payjoin directory, plus added a println! for the selected relay. Each receive run then only performs the random relay selection and exits.

Works as expected:

-> % RUST_LOG=debug payjoin-cli receive 1000
Selected OHTTP relay: https://pj.bobspacebkk.com/
DEBUG reqwest::connect: proxy(https://pj.bobspacebkk.com/) intercepts 'https://payjo.in/'
-> % RUST_LOG=debug payjoin-cli receive 1000
Selected OHTTP relay: https://pj.benalleng.com/
DEBUG reqwest::connect: proxy(https://pj.benalleng.com/) intercepts 'https://payjo.in/'

Side note - fetching OHTTP keys from the achow101 relay errors out for some reason. Unrelated to this PR, opened #1614.

@benalleng benalleng requested a review from xstoicunicornx June 8, 2026 16:46
@xstoicunicornx

Copy link
Copy Markdown
Collaborator

Still in the process of reviewing, but maybe additional relays should be added so we don't have a bunch of warnings about only one relay being configured when running the test.sh?

see warnings
2026-06-08T17:23:55.128916Z  WARN payjoin_cli::app::config: Only one OHTTP relay is configured. Add more ohttp_relays to improve privacy.
Listening at 0.0.0.0:59136. Configured to accept payjoin at BIP 21 Payjoin Uri:
bitcoin:bcrt1qlu3hdkzsyc4h4w5yyh3nnjm48vp7tqk5hzhnzx?amount=0.00054321&pj=HTTPS://LOCALHOST:59136/
2026-06-08T17:23:55.437479Z  WARN payjoin_cli::app::config: Only one OHTTP relay is configured. Add more ohttp_relays to improve privacy.
Using OHTTP Keys from config
Receive session established
Request Payjoin by sharing this Payjoin Uri:
bitcoin:bcrt1qumljkmcqw74p7g2z2wtxvj7nlwa8kcma34glzp?amount=0.00054321&pjos=0&pj=HTTPS://LOCALHOST:59054/4NY89ERFPPADZ%23EX19DXZS6S-OH1QYPPDS8ZQWSQYY5UY26329Q2L9HPYF70STP8J5SVPPE6MSX8M4U5LEG-RK1QGN2XH76FL4QA5PMN2GYX2HS95JRXUFAR2R6FKF4CNHDULXD69E6U
Sending Original PSBT to https://LOCALHOST:59136/?v=1&additionalfeeoutputindex=1&maxadditionalfeecontribution=68&minfeerate=1
Using OHTTP Keys from config
Error: Interrupted
2026-06-08T17:23:55.779516Z  WARN payjoin_cli::app::config: Only one OHTTP relay is configured. Add more ohttp_relays to improve privacy.
Listening at 0.0.0.0:59162. Configured to accept payjoin at BIP 21 Payjoin Uri:
bitcoin:bcrt1qe6hzxglaklp2anhs9acyqs2fv0ucl72tr80flw?amount=0.00054321&pj=HTTPS://LOCALHOST:59162/
Receive session established
Request Payjoin by sharing this Payjoin Uri:
bitcoin:bcrt1q9z0zzal28d6mhpfrremwefa6wl3slvhtnh7gxw?amount=0.00054321&pjos=0&pj=HTTPS://LOCALHOST:59053/JTMXFVR3QQ2HX%23EX19DXZS6S-OH1QYP4YQ9049TZPT6W3UQCZWLT2MEJ849NN9G6ANVAN8CQ85UPNZV5GUQ-RK1QVUGC9JALGU8Q8G9KN90JC5TDP3EEZYKVM04QYVFTDR7F03SHZUWJ
2026-06-08T17:23:55.896607Z  WARN payjoin_cli::app::config: Only one OHTTP relay is configured. Add more ohttp_relays to improve privacy.
Error: Interrupted
2026-06-08T17:23:56.029559Z  WARN payjoin_cli::app::config: Only one OHTTP relay is configured. Add more ohttp_relays to improve privacy.
Bootstrapping private network transport over Oblivious HTTP
Bootstrapping private network transport over Oblivious HTTP
Payjoin sent. TXID: 8534cc34306b0a4247e0d0007e0dcaf1938f66fab86027004dcb41e99bedfcb1
test e2e::send_receive_payjoin_v1 ... ok
Sending Original PSBT to https://LOCALHOST:59162/?v=1&additionalfeeoutputindex=1&maxadditionalfeecontribution=68&minfeerate=1
Posted Original PSBT...
Payjoin sent. TXID: d42fc30b1c2d50d343d0ffadba38ef1c74e0d954b97544f3ab86667f6b478839
Posted Original PSBT...
test e2e::send_receive_payjoin_v2_to_v1 ... ok
No response yet.
No response yet.
Error: Interrupted
2026-06-08T17:23:58.676864Z  WARN payjoin_cli::app::config: Only one OHTTP relay is configured. Add more ohttp_relays to improve privacy.
Bootstrapping private network transport over Oblivious HTTP
Error: Interrupted
2026-06-08T17:23:58.739621Z  WARN payjoin_cli::app::config: Only one OHTTP relay is configured. Add more ohttp_relays to improve privacy.
Polling receive request...
Broadcasted fallback transaction txid: a44652f94657a628eb328a6e10a5a31152a324deb565746568cd135f8aba164d
test e2e::sender_cancel_v2 ... ok
Got a request from the sender. Responding with a Payjoin proposal.
Fallback transaction received. Consider broadcasting this to get paid if the Payjoin fails:
020000000001017c60b59b4e577c3ef7515d60c170128e2bfcce33fca3fada6c7da9b44fa05d4c0100000000fdffffff02421d052a01000000160014f570709b9dd1972518bcdca67621f2d5cf9a354a31d4000000000000160014e6ff2b6f0077aa1f21425396664bd3fbba7b637d02473044022079a71bd89d99668996bdf054252b8cae8d328c68fb5876f0827c5ec5e1c3801302202010e59122074995bb45ac6cbd038135b3a90e2790a87afac6d354c674c194080121023027df736d916a0854239db3ba9de734855d1694e3ec6ba7f5099461f751a2fa68000000
Response successful. Watch mempool for successful Payjoin. TXID: b1885918869adc828b60ade2008a64b60d988f1b6aa61b9069db842c02f68cb8
2026-06-08T17:23:59.097223Z  WARN payjoin_cli::app::config: Only one OHTTP relay is configured. Add more ohttp_relays to improve privacy.
Session 1 error: No payjoin transaction detected in mempool within 5s, stopping.
2026-06-08T17:24:04.121488Z  WARN payjoin_cli::app::config: Only one OHTTP relay is configured. Add more ohttp_relays to improve privacy.
Bootstrapping private network transport over Oblivious HTTP
Proposal received. Processing...
Payjoin sent. TXID: b1885918869adc828b60ade2008a64b60d988f1b6aa61b9069db842c02f68cb8
2026-06-08T17:24:04.694203Z  WARN payjoin_cli::app::config: Only one OHTTP relay is configured. Add more ohttp_relays to improve privacy.
Payjoin transaction detected in the mempool!
Session 1 completed.
2026-06-08T17:24:04.932315Z  WARN payjoin_cli::app::config: Only one OHTTP relay is configured. Add more ohttp_relays to improve privacy.
No sessions to resume.
2026-06-08T17:24:04.949411Z  WARN payjoin_cli::app::config: Only one OHTTP relay is configured. Add more ohttp_relays to improve privacy.
No sessions to resume.
test e2e::send_receive_payjoin_v2 ... ok

test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 47.20s

@benalleng benalleng mentioned this pull request Jun 8, 2026
2 tasks

@xstoicunicornx xstoicunicornx left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Sorry to completely derail this PR, but if the ultimate intention is to randomize the relay chosen for each request I'm not understanding the additional utility of this PR.

Additionally it seems much simpler to just update the RelayManger implementation to something like below, which only allows the use of choose_relay which always picks a random relay and would only touch a couple places in the existing code base that would need to be updated.

Is there a reason to store the currently selected relay when we always want it to be random?

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

impl RelayManager {
    pub fn new(config: Config) -> Self { RelayManager { config, failed_relays: Vec::new() } }

    pub fn add_failed_relay(&mut self, relay: Url) { self.failed_relays.push(relay); }

    pub fn choose_relay(&self) -> Result<Url> {
        use payjoin::bitcoin::secp256k1::rand::prelude::SliceRandom;
        let relays = self.config.v2()?.ohttp_relays.clone();
        let remaining_relays: Vec<_> =
            relays.iter().filter(|r| !self.failed_relays.contains(r)).cloned().collect();

        if remaining_relays.is_empty() {
            return Err(anyhow!("No valid relays available"));
        }

        match remaining_relays.choose(&mut payjoin::bitcoin::key::rand::thread_rng()) {
            Some(relay) => Ok(relay.clone()),
            None => Err(anyhow!("Failed to select from remaining relays")),
        }
    }
}

Comment on lines +642 to +644
let ohttp_relay = self
.unwrap_relay_or_else_fetch(Some(&session.pj_uri().extras.endpoint()), relay_manager)
.await?;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Unrelated to this PR, but shouldn't this relay selection be inside the loop so that a different relay can be used for each poll?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Yes, though with a lot of the discussion from #919 I do want to be sure that this is not going to be immediately overwritten or cause some additional complexity in the refactor so I am trying to keep these PRs as minimal as possible before we transition to the AS-aware relay selection.

@benalleng

Copy link
Copy Markdown
Collaborator Author

Responding to #1399 (review) it was my attempt to keep this PR as minimal and focused on the #1391 issue at hand instead of creating a full payjoin-cli refactor here.

Immediately after merging this is incoming https://github.com/va-an/rust-payjoin/tree/refactor/relay-manager which will merge all the functions relevant to RelayManager into methods. After which I think it is perhaps appropriate to address better randomization.

@benalleng

Copy link
Copy Markdown
Collaborator Author

Superseded by #1628

@benalleng benalleng closed this Jun 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

All sessions in resume share the same relay instead of randomizing independently

9 participants