Skip to content

Tracking: relay-observable behavioral fingerprinting #1586

@bc1cindy

Description

@bc1cindy

payjoin v2 uniffi is sans-IO: it hands integrators create_poll_request(ohttp_relay), create_post_request(ohttp_relay), create_error_request(ohttp_relay) and does not expose fetch_ohttp_keys over uniffi (only a test helper at test_utils.rs:167); the bindings ship per-language fetch helpers instead, e.g. Python http.py does the GET with httpx. It returns Request objects and leaves the HTTP transport (Cake uses http, BBM Dio, the Python binding httpx), the poll loop, the relay choice, the retry policy and the fetch frequency to each integrator. OHTTP hides request content but not timing, request counts, or relay choice, so an OHTTP relay or directory can tell integrations apart by behavior alone.

  • Poll cadence splits three ways, none shared. payjoin-cli, ldk-node and Boltz re-poll in a loop with no client-side timer, so their cadence is the directory's ~30s server-side long-poll. The mobile wallets impose a fixed client timer instead: BBM polls every 5s via Timer.periodic (constants.dart:79 used at pdk_payjoin_datasource.dart:409), and cake polls every ~2s via sleep (payjoin_receive_worker.dart:118). Liana does neither, (lianad/src/config.rs:80) , it fires a single poll per chain-poller tick, default ~30s, with no dedicated payjoin loop. Three distinct temporal signatures, so the interval alone distinguishes the integration. The mobile timers are largely a lifecycle constraint, a backgrounded app cannot reliably hold a 30s connection open, which is why they diverge from the long-pollers.

  • Relay selection the relay set and the selection strategy both differ: cake and bull bitcoin mobile ship the same built-in three-relay list (achow101 / bobspacebkk / cakewallet) but consume it differently: cake picks one at random per poll (manager.dart:33-40), while BBM shuffles the whole list once and tries entries in order, breaking on the first success (constants.dart:68-76). ldk-node takes a user-config list and walks it from a random starting index (relay_order). Liana and Boltz each use a single relay, Liana user-config, Boltz hardcoded. payjoin-cli picks a relay at random at bootstrap and then caches it for every poll in the session. Because the cache lives in a process-wide RelayManager, all resumed sessions converge on that one relay.

  • fetch_ohttp_keys retry payjoin fetch_ohttp_keys is a single GET with no retry and is not exposed over uniffi, so every integrator wraps it with its own retry policy and a different attempt count. Cake retries up to 6 times, each on a fresh random relay (manager.dart:223-253); BBM tries up to 3, walking its shuffled list and breaking on success (pdk_payjoin_datasource.dart:58-71); payjoin-cli loops over its config list, picking a random un-failed relay each time up to N attempts, failing over on a connection error but not on an unexpected status code; ldk-node walks its config list (≤N); and Liana has a single relay, so there is no fallback burst. The native fetch is exposed per language (Expose native fetch_ohttp_keys in every supported downstream language #1362) but single-shot, so the retry policy is left to each integrator

  • fetch_ohttp_keys trigger frequency every code path that reaches fetch_ohttp_keys is a relay/directory touch. what an observer sees is a fetch, and the set and frequency of triggers differ per integration, so a wallet is fingerprintable by how often it spins one up. Cake reaches it via UX events, app resume, sync complete, wallet switch, settings toggle, and dashboard enable, plus one protocol-level path during a sender's checkIsOwned, all funnelling through getUnusedReceiver → initReceiver and firing only on a cache miss; notably app resume and sync complete are deterministic/recurring events, exactly the pattern Document ohttp relay best practice #1547 discourages. BBM reaches it via three user-initiated events: opening the Receive screen (creates a receiver), opening the Status page (a bundle of ~10 service health checks, payjoin being one), and pull-to-refresh on that page. payjoin-cli, ldk-node and Liana never fetch automatically, only when the user initiates a payjoin receive and their resume/poll loops use create_poll_request, not fetch_ohttp_keys. Boltz fetches exactly once, at manager startup.

poll cadence and relay selection are linked: #919's windowed selection rotates relays per ~30s window (implemented in #1514), which assumes a long-poll-style cadence, a few requests per window; the 2s/5s mobile timers don't fit that model, so cadence has to converge for windowed selection to behave as intended.

all four come from the same root: the sans-IO surface (above) leaves transport, the loop, retry, relay choice and fetch frequency to each integrator, so each implements them differently.

on making them uniform, one anonymity set: having the lib own the loop (a shared poll/retry/fetch path) would mean the lib drives transport, so no longer sans-IO; exposing the same decisions as pure-function policy (e.g. next_poll_at(), select_relay(), retry_policy(), should_fetch_keys()) keeps sans-IO;

the io feature already scopes reqwest to fetch_ohttp_keys alone; expanding to the full loop multiplies that cost across bindings/runtimes/targets.

existing work covers pieces but not the cross-integration fix on its own:

the fix has two halves: a shared mechanism in the lib (a target to converge on), then per-integration adoption downstream. should this be addressed? where? - in the lib (e.g., shared loop, pure-function policy), per integration (convergent defaults), in docs (best-practice guidance), or some mix?

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions