Skip to content

Document best client-side BIP-77 health check practice (if any) #1328

Description

@DanGould

Which crate is this feature request for (if any)?

  • payjoin
  • 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

Activity

  1. added
    documentationImprovements or additions to documentation
    questionFurther information is requested
    on Feb 12, 2026
  2. bc1cindy commented on Mar 3, 2026

    @bc1cindy
    Contributor

    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 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:90 checkStatus() 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

    // 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_keys to 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 hitting achow101 first. Cake Wallet mitigates this with Random.secure()
    That the user is on BIP-77: both hardcode payjo.in as the directory

    dart// 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_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

    // 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:

    1. 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.
    
    1. 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?

  3. DanGould commented on Mar 4, 2026

    @DanGould
    MemberAuthor

    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.

  4. bc1cindy commented on Mar 4, 2026

    @bc1cindy
    Contributor

    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.

    thank you @DanGould

    I'll be happy to work on it. it's really good to be around again

  5. added 2 commits that reference this issue on Mar 4, 2026
    d5580ab
    52c370e
  6. bc1cindy commented on May 9, 2026

    @bc1cindy
    Contributor

    we have more info #919 (comment)

  7. bc1cindy commented on May 9, 2026

    @bc1cindy
    Contributor

    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

  8. bc1cindy commented on May 10, 2026

    @bc1cindy
    Contributor

    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)

  9. 2 remaining items

  10. DanGould commented on May 12, 2026

    @DanGould
    MemberAuthor

    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

  11. added this to the payjoin-cli-1.0 milestone on May 12, 2026
  12. bc1cindy commented on May 12, 2026

    @bc1cindy
    Contributor

    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?

  13. nothingmuch commented on May 12, 2026

    @nothingmuch
    Contributor

    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

  14. bc1cindy commented on May 12, 2026

    @bc1cindy
    Contributor

    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

    sounds good? wdyt?

  15. bc1cindy commented on May 12, 2026

    @bc1cindy
    Contributor

    there's no conflict

    @nothingmuch its true, thanks for clarifying

  16. bc1cindy commented on May 12, 2026

    @bc1cindy
    Contributor

    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)

    #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, 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
    • keep everything else (decoder, validator, NetworkView, PinnedUrl, AS filter, select_directory, fallback)
    • promote asmap to lib, moving from payjoin-cli/src/app/v2/asmap.rs to payjoin/src/core/asmap/ feature = "asmap" on by default

    seems viable, wdyt?

  17. Mshehu5 commented on May 12, 2026

    @Mshehu5
    Contributor

    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

  18. bc1cindy commented on May 12, 2026

    @bc1cindy
    Contributor

    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?

  19. bc1cindy commented on May 13, 2026

    @bc1cindy
    Contributor

    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

    1. App resume
    2. Sync complete
    3. Wallet switch
    4. Settings toggle (privacy settings)
    5. Dashboard enable

    1 protocol-level trigger (during payjoin session itself) checkIsOwned from sender, creates next receiver while processing current

    fetchOhttpKeys (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):

    1. Open Receive screen: creates new receiver
    2. Open Status page: triggers bundle of 10 health checks (payjoin is one)
    3. 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

  20. bc1cindy commented on May 26, 2026

    @bc1cindy
    Contributor

    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:

    1. RelaySelector absorbs RelayManager, the selection logic is pure, and the selector holds the failed_relays memory it consumes. tracking moves into the selector (the client just owns the instance lifetime and persistence) selected_relay cache (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_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

    1. frequency/timing Document best client-side BIP-77 health check practice (if any) #1328 (comment) : RelaySelector doesn'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

    1. about integrations, we could contribute to integrations to align all tied by one principle: canonicalize and adopt.

    2. 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

    does it make sense to start with 1?

  21. bc1cindy commented on Jun 1, 2026

    @bc1cindy
    Contributor

    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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

documentationImprovements or additions to documentationquestionFurther information is requested

Type

No type

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions