Skip to content

Tracking: integration workarounds the API should absorb #1950

Open

Description

@DanGould

Every payjoin integration carries its own code to get around gaps in payjoin and payjoin-ffi. A read of nine integrations (wallets, a server plugin, a swap backend, BDK-CLI) found about 110 such sites. I've identified patterns in this common code, listed below. payjoin-cli carries four of them itself, plus a fifth in an open PR, which is part of why they spread. Many of these are known but how they spread and their inventory was not yet explicit.

In a recent discussion, fixing these and integrating only in wallets that have the common fixes for now while we work on in-flight integrations is the way to stop us from adding new ones and getting overwhelmed by custom code for integrations.

Each pattern is one fix here and a copy in every downstream wallet. Until it's fixed, every new integration and every new language binding inherits the hack. This issue lists the workarounds ranked by how many integrations each one affects, creating an order to plan to close them that makes integration work the easiest.

A row is done when the workaround is deleted from an integration's code, not when the PR here merges. Each integration name links to the code that row should replace. "(PR)" marks code in a pull request that hasn't merged yet.

What belongs here

A workaround is a library gap if any of these hold:

  1. The wallet reconstructs something the library already knows (the fallback transaction, which outpoints the receiver committed, URI parameters).
  2. Two correct wallets would write nearly the same code.
  3. Getting it wrong loses funds or privacy in a way that's specific to payjoin. The safe path should be the library's default, even where the decision itself is the wallet's.

Otherwise it's wallet policy, and the library at most offers a hook. Which relays a wallet trusts is policy. Failing over when one is down is not.

Open gaps

Gap Fix Integrations
Relay selection and failover over the wallet's relay list #1960 - #1694 (a stateless relay-order function; #1696 won't land as is), #1697 Bull Bitcoin, Cake, Wasabi (PR), BTCPay, Satsigner, BDK-CLI, Liana (PR), Boltz (PR), Cove, payjoin-cli
Directory poll timing. Each integration writes its own poll loop, and among ten of them there are at least seven different timings: an immediate re-poll, a fixed sleep after each response (0.4 to 5 s), or a fixed timer (3 to 30 s), which also delays the first poll . The directory can tell wallets apart by their timing, and a re-poll timed from the response shows the client's round-trip time to its relay. A client timeout shorter than the directory's 30 s hold makes every empty poll look like a relay failure A poll schedule owned by payjoin and exposed in payjoin-ffi. The timing rule itself waits on the BIP 77 poll-timing decision (#1663, #1658) Bull Bitcoin (PR), Cake, Wasabi (PR), BTCPay, Satsigner, BDK-CLI, Liana (PR), Boltz (PR), Cove, payjoin-cli
OHTTP key fetching from every binding, how long to cache keys, and recovering when a directory rotates them. A session keeps the key it started with, so rotation also reaches wallets that never fetch keys themselves #1250, #1362, #1489, #1449, #615 Bull Bitcoin, Cake, Wasabi (PR), BTCPay, Satsigner, BDK-CLI, Liana (PR), Boltz (PR), payjoin-cli
One parser and builder for bitcoin: URIs. Wallets run a second parser for lightning, sp, label and req-, and pj gets re-spliced on the way through payjoin/bitcoin_uri#16, then #1867 Bull Bitcoin, Cake, Wasabi, BTCPay, Satsigner, Cove
Settlement status (payjoin or fallback) computed from wallet data. Most integrations predict the proposal txid and look it up locally. Bull Bitcoin's open PR asks its Electrum server for both txids, and a lookup for a fallback that was never broadcast tells the server the transaction is a payjoin #1971 (classify over the sender's and receiver's prevouts), keeping the predicted proposal txid internal, with docs that point light clients at their own scripts. Sender-side detection isn't covered yet. Electrum light clients on BDK also need a per-script lookup in bdk-ffi Bull Bitcoin (PR), Cake (PR), BTCPay, Satsigner, Liana (PR), Boltz (PR)
Declining a request for the receiver's own reasons, such as an amount below its minimum or a closed invoice. cancel() sends nothing to the sender, who learns of the decline only from the chain. The only way to send an error reply is to fail a check closure on purpose new: a decline on the receiver states before the proposal that posts an error reply, so cancel() and the reply become one call. BIP 77 also needs to specify the v2 error reply the library already sends Bull Bitcoin (PR), BTCPay, Cake (PR), Liana (PR), payjoin-cli
Reading what a session recorded. SessionHistory offers only pj_uri(), fallback_tx() and status(), and the bindings' ReceiverSessionOutcome has no accessors, so wallets parse the event JSON for the amount received or a closed session's txid use SessionHistory and new #1692 for settlement status. Calculate net wallet change (classic wallet history) OR what sender paid (subtract Original from Fallback to find out what receiver fee share was) Cake (PR), Liana (PR), Boltz (PR)
Seen-input tracking. The anti-probing check on previously seen inputs depends on each wallet keeping its own store. Some wallets ship with the check off, and a store that records inputs before the transition is saved can make a resumed session reject itself Docs and payjoin-cli ordering now; library-held store in 2.0 (#894) BTCPay, BDK-CLI, Boltz (PR), payjoin-cli
The receiver's own output in the sender's transaction, to replace it, forward part of it, or credit it #1949, plus new: expose the identified receiver outputs Bull Bitcoin, Cake (PR), BTCPay, payjoin-cli (PR)
Reading pj details (expiry, version, receiver key, output substitution) from a parsed URI in the bindings remove dependence on these unless critically necessary; if necessary, create an explicit accessor/affordance Cake (PR), Wasabi, Satsigner
Replay errors in the bindings. They don't offer expiry_fallback_tx(), and they can't tell a storage failure from a corrupt log new Bull Bitcoin, Wasabi (PR), BTCPay (PR)
Cancelling a receiver session from whatever state replay returns new: ReceiveSession::cancel() Cake (PR), Liana (PR), payjoin-cli
Building receiver inputs in the bindings. payjoin-ffi has only InputPair::new, so wallets assemble the PSBT input by hand, and an unsigned Taproot coin fails without a hand-computed weight #1955 (expose new_p2wpkh, new_p2tr_keyspend and the other typed constructors) BTCPay plugin, Bull Bitcoin, Cake

Every fix above can land additively in payjoin. payjoin takes only additive changes for the rest of 2026. payjoin-ffi is still 0.x and tied to UniFFI's version, so the binding error cleanup may ship as one more breaking binding release. Breaking changes to payjoin belong in the payjoin-2.0 milestone.

Already fixed, waiting on upgrades

Some workarounds are already handled by 1.0 through 1.2. The non-blocking checklists and finalize_signed_proposal (#1446), is_expired() on request and replay errors, the Transient / Fatal / Storage split on persisted errors (#1724, with is_transient() and is_fatal() in Rust), and the sender's PendingFallback all remove code from integrations that upgrade or adopt them. No integration is on 1.2 yet. Most already pin a release with is_expired() and the error split, so for those the work is using what they already have.

#749 is mostly done. What remains of the typed-errors work in #1422 is documentation.

Not tracked here

New language bindings should say which of these gaps they'd have to work around for their target integration. If any, close the gap here first. The partner tracking issues (#1204, #1205, #1206) should link rows here instead of listing library work themselves.

Activity

  1. benalleng commented on Oct 7, 2026

    @benalleng
    Collaborator

    If a UI wants to show the user an upcoming expiry we need to expose it publicly in the API. But before we go ahead and add this to the list I would like some feedback on whether we think this is actually something useful to show a given payjoin user.

  2. benalleng commented on Oct 7, 2026

    @benalleng
    Collaborator

    Having given some more thought I think that the expiry is not necessary for users to see at a session level.

    • The receiver created the session expiry explicitly and should be made aware of the implications at creation time.
    • The sender, having been given the uri can see the expiry at scan time. It is possible it is useful to show the expiry at that moment but it is not needed through the whole session.

    I thought maybe there was some value in projecting to the user a sense of urgency to an upcoming expiration but I don't think out-of-band communication is a meaningful way to get more payjoins done

  3. DanGould commented on Oct 7, 2026

    @DanGould
    MemberAuthor

    In a lightning invoice it's a critical affordance to see the expiration, because after it's gone, the whole thing is useless. Payjoin degrades to an ordinary non-interactive transaction so it's less critical. Also, the receiver at least could calculate it based on their config value + session creation time if they really needed a hack beond reading it from the URI. Finally, if this was something we find we should regularly display we can just create the affordance for it. But for now I think the "fix" for the parse-expiry-from-uri-param workaround should just be to remove any behavior that depends on it completely.

  4. DanGould commented on Oct 16, 2026

    @DanGould
    MemberAuthor

    Edited with new directory poll timing row, revisited in SatoshiPortal/bullbitcoin-mobile#2977, Updated the settlement status row, and introduced "Declining a request for the receiver's own reasons" because the decline message fixes privacy eroding sync without it for light clients.

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

Metadata

Metadata

Labels

trackingHigh-level tracking issues

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions