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
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?
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 exposefetch_ohttp_keysover uniffi (only a test helper at test_utils.rs:167); the bindings ship per-language fetch helpers instead, e.g. Python http.py does theGETwith httpx. It returnsRequestobjects and leaves the HTTP transport (Cake useshttp, BBMDio, the Python bindinghttpx), 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 cadencesplits three ways, none shared.payjoin-cli,ldk-nodeandBoltzre-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 5sviaTimer.periodic(constants.dart:79used atpdk_payjoin_datasource.dart:409), andcake polls every ~2sviasleep(payjoin_receive_worker.dart:118).Lianadoes 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 selectiontherelay setand theselection strategyboth 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-wideRelayManager, all resumed sessions converge on that one relay.fetch_ohttp_keys retrypayjoinfetch_ohttp_keysis a single GET with no retry and is not exposed overuniffi, 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 nativefetch_ohttp_keysin every supported downstream language #1362) but single-shot, so the retry policy is left to each integratorfetch_ohttp_keys trigger frequencyevery code path that reachesfetch_ohttp_keysis 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'scheckIsOwned, all funnelling throughgetUnusedReceiver → initReceiverand 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 usecreate_poll_request, notfetch_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 choiceandfetch frequencyto 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
iofeature already scopesreqwesttofetch_ohttp_keysalone; 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:
strategyas pure-function (payjoin-cli); doesn't address divergent relaylists, universality across bindings, or the other 3 pure-functionsfetch_ohttp_keysin every supported downstream language #1362 - exposes the single-shot nativefetch_ohttp_keysper language;retryandtrigger frequencystay per-integrationthe 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?