You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Document best client-side BIP-77 health check practice (if any) #1328
payjoin-cli (maybe, if we ever include an infastructure check as reference. Seems like implementations like this.
Question
Bull Bitcoin Mobile is fetching OHTTP Keys from its receiving directory every time it starts up through an OHTTP Relay as a way to check the health of the infrastructure. Please consider what metadata we're leaking by doing this. Seems like the type of thing that would be best if it were a uniform behavior but also one that is not enforceable. I noticed there was a health check in BBMobile and was very curious if it leaked IP. Fortunately, it uses the Relay and therefore seems to have approximately the same network threat model as the protocol itself, granted timing is a bit different.
Consider what Cake is doing as well
Is this best practice? If so why not. If yes, document it
I've been digging around this issue, analyzed the source code of both Bull Bitcoin Mobile and Cake Wallet, so i'd like to share what I've found and try to help you guys.
both implementations go through the relay correctly, the payjoin directory never sees the user's IP.
the health check for BB mobile isn't triggered on startup, it only fires when the user manually opens the settings screen.
BB mobile calls fetchOhttpKeyAndRelay in two places: as a pure health check in settings, and when creating a receiver session. Neither uses random relay selection.
settings_router.dart:90checkStatus() it is only created when navigating to settings all_settings_screen.dart:32 it's triggered when the screen is opened wallet_home_app_bar.dart:135 settings only opens when the user taps it
for Cake Wallet though, it fires every time the app resumes from background, but only if bitcoin wallet and payjoin are enabled. Cake Wallet calls fetch_ohttp_keys to resume active payjoin sessions. The timing is a side effect of a necessary operation, not a health check.
moreover, BB mobile wallet fixed relay order is not limited to the health check. It appears in every payjoin communication in pdk_payjoin_datasource.dart:
bullbitcoin-mobile % rg ohttpRelayUrls
lib/core/utils/constants.dart
50: static const List<String> ohttpRelayUrls = [
lib/core/payjoin/data/datasources/pdk_payjoin_datasource.dart
58: for (final ohttpRelayUrl in PayjoinConstants.ohttpRelayUrls) {
562: for (final ohttpRelay in PayjoinConstants.ohttpRelayUrls) {
616: for (final ohttpRelayUrl in PayjoinConstants.ohttpRelayUrls) {
650: for (final ohttpProxyUrl in PayjoinConstants.ohttpRelayUrls) {
703: for (final ohttpRelay in PayjoinConstants.ohttpRelayUrls) {
and there is no use of random anywhere in BB mobile wallet relay selection code
rg "Random\|random\|shuffle" lib/ -n --type dart | grep -i "relay\|ohttp" | head -10
rg "Random.secure\|Random()" lib/ -n | head -10
(no results)
IP doesn't leak, the relay handles that, but some metadata does leak at the network layer regardless of OHTTP, just by nature of making an HTTP request:
Timing: the exact moment of the request reveals when the user opened settings or creates a receiver session (BB mobile) or resumed the app (Cake Wallet)
Which relay was used: BB mobile creates an identifiable pattern by always hitting achow101 first. Cake Wallet mitigates this with Random.secure()
That the user is on BIP-77: both hardcode payjo.in as the directory
I don't see the problem as the health check itself, it's the fixed relay order. Using random would be good practice, just like Cake Wallet does.
fetch_ohttp_keys already documents why you should always route through a relay. What's missing is guidance on picking the relay randomly, and tracking failed relays for resilience. The payjoin-cli already implements it correctly.
random selection:
// payjoin-cli/src/app/v2/ohttp.rs:70
70: match remaining_relays.choose(&mut payjoin::bitcoin::key::rand::thread_rng()) {
71- Some(relay) => relay.clone(),
72- None => return Err(anyhow!("Failed to select from remaining relays")),
73- };
failed relays:
// payjoin-cli/src/app/v2/ohttp.rs:59
59: let failed_relays =
60: relay_manager.lock().expect("Lock should not be poisoned").get_failed_relays();
61-
62- let remaining_relays: Vec<_> =
63: relays.iter().filter(|r| !failed_relays.contains(r)).cloned().collect();
64-
65- if remaining_relays.is_empty() {
66- return Err(anyhow!("No valid relays available"));
--
114: .add_failed_relay(selected_relay);
BB mobile does a simple catch, just continue to the next relay in the list
my take is that would be good to add docs in two places:
payjoin/src/core/io.rs, to guide library consumers:
/// * `ohttp_relay`: The http CONNECT method proxy to request the ohttp keys from a payjoin
/// directory. Proxying requests for ohttp keys ensures a client IP address is never revealed to
/// the payjoin directory.
///
/// When multiple relays are available, callers SHOULD select one at random rather than
/// always using the same relay. A fixed selection order creates a fingerprintable pattern
/// at the network layer even though the IP is protected by OHTTP.
/// See `payjoin-cli`'s `RelayManager` for a reference implementation that combines
/// random selection with failed-relay tracking for resilience.
payjoin-cli/src/app/v2/ohttp.rs, to guide implementers reading the CLI as reference:
/// Manages OHTTP relay selection with privacy and resilience in mind.
///
/// Relays are selected randomly from the configured list to avoid creating
/// a fingerprintable pattern at the network layer. Even though OHTTP protects
/// the user's IP address, always contacting the same relay in the same order
/// reveals metadata about the client's behavior.
///
/// Failed relays are tracked and excluded from future selections until
/// the session is reset, providing resilience without sacrificing privacy.
pub struct RelayManager {
/// Fetches OHTTP keys using a randomly selected relay from the configured list.
///
/// Random relay selection prevents network-layer fingerprinting. See [`RelayManager`]
/// for details on the selection and failure-tracking strategy.
async fn fetch_ohttp_keys(
This makes sense to me. Happy to accept the PR and documentation and to endorse an issue/PR on bbmobile referencing here. Great so see you around again @bc1cindy ! Thanks for this measured, through contribution.
This makes sense to me. Happy to accept the PR and documentation and to endorse an issue/PR on bbmobile referencing here. Great so see you around again @bc1cindy ! Thanks for this measured, through contribution.
we seem to have a consensus in per request, stateless and random?
if so, the RelayManager cache that still bypasses random selection in payjoin-cli, tracked in #1391#1385 (comment) and following this approach would align with #919 (comment) to have a stateless base
im happy to send a PR updating the code and doc, if makes sense
Another issue @benalleng raises is that the lists of relays sometimes contain isolated infrastructure (e.g. ~ohttp.cakewallet.com for Cake Wallet) that other apps don't use. It seems we need to encourage best practice behavior and discourage isolated infra
I believe there is one textual conflict with BIP 77 to flag:
only directory-side early termination of polls (per @nothingmuch's "randomizing polling traffic patterns" #919 ) requires a protocol change
BIP77
GET requests on an empty mailbox should block until a message is posted or a timeout occurs. The timeout should be 30 seconds because that will not exceed the default timeout for most HTTP clients.
with some probability, the directory could terminate polling requests earlier than the maximum timeout even when there is no data.
BIP 77 today allows only two termination conditions for an empty-mailbox poll: message posted, or 30s timeout. asmap should be used for relay selection #919 adds a third: directory terminates early for traffic shaping
the 202 ACCEPTED status currently has a single meaning ("still empty, keep waiting"). Distinguishing an early-shaped 202 from a natural-timeout 202 needs a new signal (status code, header, or response field)
a new optional "Directory Traffic Shaping" section in the BIP would define the directory's policy and normative client behavior on receiving shaped responses (e.g. switch relay before retrying)
everything else in #919 (POST/POLL segregation, AS-aware, time-dependent H, POLL reuse, cover traffic via self-messaging) stays within current BIP 77
I believe there is one textual conflict with BIP 77 to flag:
only directory-side early termination of polls (per @nothingmuch's "randomizing polling traffic patterns" #919 ) requires a protocol change
there's no conflict, the directory can choose any value for this timeout, we recommended 30 seconds for compatibility.
if the directory occasionally and randomly shortens this frustrate traffic analysis by relays, then that is entirely within the recommendations, which btw are just recommendations not a requirement.
in the future we might consider relay selection considerations the spec, we can revisit that after all this is actually implemented and others have checked my reasoning
In general yes. given that this has the same metadata leakage as the protocol messages them selves and every integration will end up solving this in a similar way, does it make sense to update our API to solve this?
@arminsabouri yes, payjoin::io::fetch_ohttp_keys currently takes a single ohttp_relay, so each wallet (BBMobile, Cake, ldk-node, payjoin-cli) implements its own selection/retry wrapper around it. changing the signature to &[Url] and returning (OhttpKeys, Url) moves selection into the lib (one canonical implementation, every consumer inherits it)
same applies to the other lib functions that take ohttp_relay. The other 6 live in send/v2/mod.rs and receive/v2/mod.rs (create_v2_post_request, create_poll_request, etc.). Today they force the caller to pre-pick a single relay and reuse it for the whole session; applying the same &[Url] shape lets each session message run selection inline
RelayManager in payjoin-cli (which existed only to remember the pre-picked relay) loses its reason to exist. This drops the caching that bypasses per-request random (#1391, #1399), and the per-session vs per-request question (#1385) is resolved by the API supporting either
happy to anchor with this, since its small and unblocks the rest
i think it's possible to do the relay selection statelessly so that seem more straightforward to ship as part of the main crate
I believe asmap is simple and important enough to live in the main crate, what do you guys think about it?
@Mshehu5's already implemented the binary .dat decoder , so users can point at bitcoin-core/asmap-data as canonical source (same as Bitcoin Core's -asmap flag)
drop RelayRole::Sender|Receiver, sender-reverse, per-pubkey pinning
add RequestKind::{Post, Poll}, MailboxId, time parameter t = (unix_seconds + nonce_from_receiver_key) / 30, hash H(receiver_key, mailbox_id, t, ASN) per-ASN, k-smallest POST selection, POLL window exclusion (avoid k-smallest at t_{i-2..i+2}, or at least t_{i-1..i+1}), POLL relay reuse across short polling intervals
Yeah true , Already working on making the PR fit the new spec.
Also checked out 0xb10c crate seems like that implementation will be better to work with and will be easier to manage since its in its own seperate repo/crate
Another issue @benalleng raises is that the lists of relays sometimes contain isolated infrastructure (e.g. ~ohttp.cakewallet.com for Cake Wallet) that other apps don't use. It seems we need to encourage best practice behavior and discourage isolated infra
@DanGould@benalleng are you thinking of governance for the relay list specifically? or would similar governance over asmap data (instead of pointing at bitcoin-core/asmap-data) help the client side too?
fetch_ohttp_keys isn't exposed over uniffi, its reimplemented per binding, so a rust signature change alone won't reach them. Exposing RelaySelector over the FFI lets rust and the bindings share one selection, once each binding is rewired to call it
that's #919 (cover traffic / fake sessions), divergent noise becomes a new fingerprint, so it only helps if its canonical
about integrations, we could contribute to integrations to align all tied by one principle: canonicalize and adopt.
asmap: [WIP] Add AS-aware relay selection #1514 already shows one default path with asmap: Option<&Asmap>, AS-aware when asmap + user_asn are present, random as the built-in fallback
ASN resolution is async and stays a pre-step. It fits the lib additively, a .with_asmap(ctx) on RelaySelector that doesn't change existing signatures and leaves non-users on random. This stays non-breaking precisely because (1) routes through RelaySelector
integrations diverge in behavior in ways that leave broader fingerprints beyond health-check timing, across poll cadence, relay selection, fetch retry, and fetch trigger frequency
opened #1586 to track this with verified evidence across integrations, plus a proposal: expose the relevant decisions as pure-function policy in the lib (e.g. next_poll_at(), select_relay(), retry_policy(), should_fetch_keys()), instead of each reimplementing the loop
Activity
bc1cindy commentedon Mar 3, 2026
hi @DanGould ,
I've been digging around this issue, analyzed the source code of both Bull Bitcoin Mobile and Cake Wallet, so i'd like to share what I've found and try to help you guys.
both implementations go through the relay correctly, the payjoin directory never sees the user's IP.
BBMobile:
Cake Wallet:
the health check for BB mobile isn't triggered on startup, it only fires when the user manually opens the settings screen.
BB mobile calls
fetchOhttpKeyAndRelayin two places: as a pure health check in settings, and when creating a receiver session. Neither uses random relay selection.settings_router.dart:90checkStatus()it is only created when navigating to settingsall_settings_screen.dart:32it's triggered when the screen is openedwallet_home_app_bar.dart:135settings only opens when the user taps itfor Cake Wallet though, it fires every time the app resumes from background, but only if bitcoin wallet and payjoin are enabled. Cake Wallet calls
fetch_ohttp_keysto resume active payjoin sessions. The timing is a side effect of a necessary operation, not a health check.It also picks a relay randomly:
BB mobile always tries in the same fixed order:
moreover, BB mobile wallet fixed relay order is not limited to the health check. It appears in every payjoin communication in
pdk_payjoin_datasource.dart:and there is no use of random anywhere in BB mobile wallet relay selection code
IP doesn't leak, the relay handles that, but some metadata does leak at the network layer regardless of OHTTP, just by nature of making an HTTP request:
Timing: the exact moment of the request reveals when the user opened settings or creates a receiver session (BB mobile) or resumed the app (Cake Wallet)
Which relay was used: BB mobile creates an identifiable pattern by always hitting
achow101first. Cake Wallet mitigates this withRandom.secure()That the user is on BIP-77: both hardcode
payjo.inas the directoryI don't see the problem as the health check itself, it's the fixed relay order. Using random would be good practice, just like Cake Wallet does.
fetch_ohttp_keysalready documents why you should always route through a relay. What's missing is guidance on picking the relay randomly, and tracking failed relays for resilience. Thepayjoin-clialready implements it correctly.random selection:
failed relays:
BB mobile does a simple catch, just continue to the next relay in the list
Cake Wallet retries up to 5 times, picking a random relay each attempt, but without tracking which relays failed, so it may retry a failing relay.
my take is that would be good to add docs in two places:
payjoin/src/core/io.rs, to guide library consumers:payjoin-cli/src/app/v2/ohttp.rs, to guide implementers reading the CLI as reference:what do you think about it?
DanGould commentedon Mar 4, 2026
This makes sense to me. Happy to accept the PR and documentation and to endorse an issue/PR on bbmobile referencing here. Great so see you around again @bc1cindy ! Thanks for this measured, through contribution.
bc1cindy commentedon Mar 4, 2026
thank you @DanGould
I'll be happy to work on it. it's really good to be around again
Document OHTTP relay random selection guidance
Add OHTTP relay selection docs to module level
bc1cindy commentedon May 9, 2026
we have more info #919 (comment)
bc1cindy commentedon May 9, 2026
we seem to have a consensus in per request, stateless and random?
if so, the
RelayManagercache that still bypasses random selection inpayjoin-cli, tracked in #1391 #1385 (comment) and following this approach would align with #919 (comment) to have a stateless baseim happy to send a PR updating the code and doc, if makes sense
bc1cindy commentedon May 10, 2026
is random a consensus too?
cake wallet -
Random.secure()per callBullBitcoin Mobile - Fisher-Yates re-shuffled on every relay-list access (multiple times per payjoin flow)
ldk-node - rotation: N permutations vs N!
payjoin-cli - random on first call, cached after
Boltz - hardcoded
Liana - single user-configured value
should we standardize on a pattern across integrations?
I understand that the algorithm itself is a behavioral fingerprint (relates to #919 )
payjoin cli random on first call, cached after would be stateless and per request with #1328 (comment)
2 remaining items
DanGould commentedon May 12, 2026
Another issue @benalleng raises is that the lists of relays sometimes contain isolated infrastructure (e.g. ~ohttp.cakewallet.com for Cake Wallet) that other apps don't use. It seems we need to encourage best practice behavior and discourage isolated infra
bc1cindy commentedon May 12, 2026
I believe there is one textual conflict with BIP 77 to flag:
only directory-side early termination of polls (per @nothingmuch's "randomizing polling traffic patterns" #919 ) requires a protocol change
BIP77
#919 randomizing polling traffic patterns
everything else in #919 (POST/POLL segregation, AS-aware, time-dependent H, POLL reuse, cover traffic via self-messaging) stays within current BIP 77
makes sense?
nothingmuch commentedon May 12, 2026
there's no conflict, the directory can choose any value for this timeout, we recommended 30 seconds for compatibility.
if the directory occasionally and randomly shortens this frustrate traffic analysis by relays, then that is entirely within the recommendations, which btw are just recommendations not a requirement.
in the future we might consider relay selection considerations the spec, we can revisit that after all this is actually implemented and others have checked my reasoning
bc1cindy commentedon May 12, 2026
@arminsabouri yes,
payjoin::io::fetch_ohttp_keyscurrently takes a singleohttp_relay, so each wallet (BBMobile, Cake, ldk-node, payjoin-cli) implements its own selection/retry wrapper around it. changing the signature to&[Url]and returning(OhttpKeys, Url)moves selection into the lib (one canonical implementation, every consumer inherits it)same applies to the other lib functions that take
ohttp_relay. The other 6 live insend/v2/mod.rsandreceive/v2/mod.rs(create_v2_post_request,create_poll_request, etc.). Today they force the caller to pre-pick a single relay and reuse it for the whole session; applying the same &[Url] shape lets each session message run selection inlineRelayManagerin payjoin-cli (which existed only to remember the pre-picked relay) loses its reason to exist. This drops the caching that bypasses per-request random (#1391, #1399), and the per-session vs per-request question (#1385) is resolved by the API supporting eitherhappy to anchor with this, since its small and unblocks the rest
sounds good? wdyt?
bc1cindy commentedon May 12, 2026
@nothingmuch its true, thanks for clarifying
bc1cindy commentedon May 12, 2026
I believe asmap is simple and important enough to live in the main crate, what do you guys think about it?
@Mshehu5's already implemented the binary .dat decoder , so users can point at
bitcoin-core/asmap-dataas canonical source (same as Bitcoin Core's -asmap flag)#1514 could align to the current #919 spec:
RelayRole::Sender|Receiver,sender-reverse, per-pubkey pinningRequestKind::{Post, Poll},MailboxId,time parameter t = (unix_seconds + nonce_from_receiver_key) / 30, hashH(receiver_key, mailbox_id, t, ASN)per-ASN,k-smallest POSTselection, POLL window exclusion (avoid k-smallest at t_{i-2..i+2}, or at least t_{i-1..i+1}), POLL relay reuse across short polling intervalspayjoin-cli/src/app/v2/asmap.rstopayjoin/src/core/asmap/feature = "asmap"on by defaultseems viable, wdyt?
Mshehu5 commentedon May 12, 2026
Yeah true , Already working on making the PR fit the new spec.
Also checked out 0xb10c crate seems like that implementation will be better to work with and will be easier to manage since its in its own seperate repo/crate
bc1cindy commentedon May 12, 2026
@DanGould @benalleng are you thinking of governance for the relay list specifically? or would similar governance over asmap data (instead of pointing at bitcoin-core/asmap-data) help the client side too?
bc1cindy commentedon May 13, 2026
audit of paths leading to
fetchOhttpKeys(or equivalent) across the 6 integrations:cake: 5 UX triggers + 1 protocol-level
1 protocol-level trigger (during payjoin session itself)
checkIsOwnedfrom sender, creates next receiver while processing currentBBMobile: 3 paths
3 UX triggers (all user-initiated):
others: 0 automatic
I guess without uniform frequency across wallets, fake-session checks stay fingerprintable by how often each wallet spins them up
bc1cindy commentedon May 26, 2026
added docs to #1547 bindings (python, dart, c#) does this close this issue from payjoin-cli-1.0?
(can below be part of payjoin-1.1 milestone?)
other points proposal:
RelaySelectorabsorbsRelayManager, the selection logic is pure, and the selector holds thefailed_relaysmemory it consumes. tracking moves into the selector (the client just owns the instance lifetime and persistence)selected_relaycache (All sessions in resume share the same relay instead of randomizing independently #1391/Split resume session relays #1399) would be the only thing dropped. per-request vs per-session, becomes the instance lifetime, which addresses Should ohttp-relay selection be done on a per session basis in the cli #1385.fetch_ohttp_keysisn't exposed overuniffi, its reimplemented per binding, so a rust signature change alone won't reach them. ExposingRelaySelectorover theFFIlets rust and the bindings share one selection, once each binding is rewired to call itRelaySelectordoesn't fix this, its each app's UX triggers.that's #919 (cover traffic / fake sessions), divergent noise becomes a new fingerprint, so it only helps if its canonical
about integrations, we could contribute to integrations to align all tied by one principle: canonicalize and adopt.
asmap: [WIP] Add AS-aware relay selection #1514 already shows one default path with asmap:
Option<&Asmap>, AS-aware when asmap + user_asn are present, random as the built-in fallbackASN resolution is async and stays a pre-step. It fits the lib additively, a
.with_asmap(ctx)onRelaySelectorthat doesn't change existing signatures and leaves non-users on random. This stays non-breaking precisely because (1) routes throughRelaySelectordoes it make sense to start with 1?
bc1cindy commentedon Jun 1, 2026
integrations diverge in behavior in ways that leave broader fingerprints beyond health-check timing, across poll cadence, relay selection, fetch retry, and fetch trigger frequency
opened #1586 to track this with verified evidence across integrations, plus a proposal: expose the relevant decisions as pure-function policy in the lib (e.g.
next_poll_at(),select_relay(),retry_policy(),should_fetch_keys()), instead of each reimplementing the loop