Repository navigation
Document best client-side BIP-77 health check practice (if any) #1328
Description
Activity
- addeddocumentationImprovements or additions to documentationImprovements or additions to documentationquestionFurther information is requestedFurther information is requested
on Feb 12, 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:
bullbitcoin-mobile % rg "checkOhttpRelayHealth" lib/core/payjoin/data/repository/payjoin_repository_impl.dart -n -A 4 119: Future<bool> checkOhttpRelayHealth() async { 120- final (ohttpKeys, ohttpRelay) = await _pdkPayjoinDatasource 121- .fetchOhttpKeyAndRelay(payjoinDirectory: PayjoinConstants.directoryUrl); 122- return ohttpKeys != null && ohttpRelay != null; 123- }Cake Wallet:
cake_wallet % rg "initReceiver" cw_bitcoin/lib/payjoin/manager.dart -n -A 5 165: return initReceiver(address); 166- } 167- 168: Future<Receiver> initReceiver(String address, [bool isTestnet = false, int retryCount = 0]) async { 169- try { 170- final ohttpKeys = await PayjoinUri.fetchOhttpKeys( 171- ohttpRelay: await randomOhttpRelayUrl(), 172- payjoinDirectory: payjoinDirectoryUrl, 173- );the health check for BB mobile isn't triggered on startup, it only fires when the user manually opens the settings screen.
BB mobile callsfetchOhttpKeyAndRelayin 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:32it's triggered when the screen is opened
wallet_home_app_bar.dart:135settings only opens when the user taps it// settings_router.dart:90 create: (_) => locator<ServiceStatusCubit>()..checkStatus(), // all_settings_screen.dart:32 void initState() { super.initState(); context.read<ServiceStatusCubit>().checkStatus(); } // wallet_home_app_bar.dart:135 onTap: () => context.pushNamed(SettingsRoute.settings.name),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_keysto resume active payjoin sessions. The timing is a side effect of a necessary operation, not a health check.// root.dart:198 if (widget.appStore.wallet?.type == WalletType.bitcoin && widget.appStore.settingsStore.usePayjoin) { bitcoin!.resumePayjoinSessions(widget.appStore.wallet!); }It also picks a relay randomly:
dart// manager.dart:37 static String randomOhttpRelayUrl() => ohttpRelayUrls[Random.secure().nextInt(ohttpRelayUrls.length)];BB mobile always tries in the same fixed order:
dart// constants.dart:50 static const List<String> ohttpRelayUrls = [ 'https://ohttp.achow101.com', // always first 'https://pj.bobspacebkk.com', 'https://ohttp.cakewallet.com', ];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 hittingachow101first. Cake Wallet mitigates this withRandom.secure()
That the user is on BIP-77: both hardcodepayjo.inas the directorydart// BBMobile constants.dart:55 static const String directoryUrl = 'https://payjo.in'; // Cake Wallet manager.dart:40 static const payjoinDirectoryUrl = 'https://payjo.in';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_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:
// 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
// pdk_payjoin_datasource.dart:67 67: } catch (e) { 68- continue; 69- }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.
// manager.dart:189 189: if (e.toString().contains("error sending request for url") && retryCount < 5) { 190: return initReceiver(address, isTestnet, ++retryCount); 191- } else { 192- rethrow; 193- }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(what do you think about it?
Reacted by spacebearReacted by Dan GouldThis 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.
Reacted by Cindy- added 2 commits that reference this issue
on Mar 4, 2026 we have more info #919 (comment)
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
is random a consensus too?
- random analysis across integrations:
cake wallet -
Random.secure()per call[`manager.dart:37-38`] static String randomOhttpRelayUrl() => ohttpRelayUrls[Random.secure().nextInt(ohttpRelayUrls.length)];BullBitcoin Mobile - Fisher-Yates re-shuffled on every relay-list access (multiple times per payjoin flow)
constants.dart:68-77 static List<String> get ohttpRelayUrls { final list = [...]; list.shuffle(Random.secure()); return list; }ldk-node - rotation: N permutations vs N!
manager.rs:164-174 let start = usize::from_ne_bytes(bytes) % count; (0..count).map(|i| relays[(start + i) % count]).collect()payjoin-cli - random on first call, cached after
ohttp.rs:17-21 + mod.rs:873-881 pub struct RelayManager { selected_relay: Option<Url>, // cached failed_relays: Vec<Url>, } let selected_relay = self.relay_manager.lock()...get_selected_relay(); match selected_relay { Some(relay) => relay, // cache hit, skips random None => unwrap_ohttp_keys_or_else_fetch(...).relay_url, }Boltz - hardcoded
mod.rs:19 const OHTTP_RELAY: &str = "https://pj.bobspacebkk.com";Liana - single user-configured value
payjoin.rs:86 ohttp_relay: form::Value<String>,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
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.
#919 randomizing polling traffic patterns
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
makes sense?
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_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?
there's no conflict
@nothingmuch its true, thanks for clarifying
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-dataas canonical source (same as Bitcoin Core's -asmap flag)#1514 could align to the current #919 spec:
- drop
RelayRole::Sender|Receiver,sender-reverse, per-pubkey pinning - add
RequestKind::{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 intervals - keep everything else (decoder, validator, NetworkView, PinnedUrl, AS filter, select_directory, fallback)
- promote asmap to lib, moving from
payjoin-cli/src/app/v2/asmap.rstopayjoin/src/core/asmap/feature = "asmap"on by default
seems viable, wdyt?
Reacted by Mshehu5- drop
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/crateAnother 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?
if we know that there are reasonable limits on how much clients do this #1547 (review) @nothingmuch
audit of paths leading to
fetchOhttpKeys(or equivalent) across the 6 integrations:cake: 5 UX triggers + 1 protocol-level
- App resume
- Sync complete
- Wallet switch
- Settings toggle (privacy settings)
- Dashboard enable
1 protocol-level trigger (during payjoin session itself)
checkIsOwnedfrom sender, creates next receiver while processing currentfetchOhttpKeys (manager.dart:227) ← initReceiver ← getUnusedReceiver ← initPayjoin (bitcoin_wallet_addresses.dart:77) ← resumePayjoinSessions ← root.dart:198 (app resume) ← on_wallet_sync_status_change.dart:43 (sync complete) ← updatePayjoinState(_, true) ← on_current_wallet_change.dart:91 (wallet switch) ← privacy_settings_view_model.dart:143 (settings toggle) ← dashboard_view_model.dart:1088 (dashboard enable) ← newPayjoinReceiver (bitcoin_wallet_addresses.dart:91) ← manager.dart:277 (during sender's checkIsOwned, protocol-level)BBMobile: 3 paths
3 UX triggers (all user-initiated):
- Open Receive screen: creates new receiver
- Open Status page: triggers bundle of 10 health checks (payjoin is one)
- Pull-to-refresh on Status page: re-triggers the same bundle
fetchOhttpKeys (pdk_payjoin_datasource.dart:61) ← fetchOhttpKeyAndRelay ← createReceiver ← ReceiveWithPayjoinUsecase.execute ← receive_bloc.dart:207 (open Receive screen) ← checkOhttpRelayHealth ← Future.wait of 10 services ← ServiceStatusCubit.checkStatus ← service_status_page.dart:22-28 (open status page) ← service_status_page.dart:41 (pull-to-refresh)others: 0 automatic
- payjoin-cli: 0 matches; keys fetched only on user-initiated payjoin
- ldk-node : no infrastructure health check (only protocol-level check_proposal etc.)
- Boltz: mod.rs:19 - hardcoded const OHTTP_RELAY
- Liana: payjoin.rs:86 - single user-configured ohttp_relay
I guess without uniform frequency across wallets, fake-session checks stay fingerprintable by how often each wallet spins them up
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 it- frequency/timing Document best client-side BIP-77 health check practice (if any) #1328 (comment) :
RelaySelectordoesn'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 fallback
ASN 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?
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
Which crate is this feature request for (if any)?
payjoinpayjoin-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