Repository navigation
Tracking: integration workarounds the API should absorb #1950
Description
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:
- The wallet reconstructs something the library already knows (the fallback transaction, which outpoints the receiver committed, URI parameters).
- Two correct wallets would write nearly the same code.
- 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
- Retry timers and backoff. The library does no I/O, and it already marks errors transient or fatal.
- Locking the receiver's contributed coins. That belongs to the wallet (see Revisiting UTXOs to be locked #937).
- Receiver coin selection quality (Improve Coin Selection API #460, Tracking: Improving receiver coin selection and output construction #1413) is mostly wallet policy and waits on research.
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
benalleng commentedon Oct 7, 2026
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.
benalleng commentedon Oct 7, 2026
Having given some more thought I think that the expiry is not necessary for users to see at a session level.
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
DanGould commentedon Oct 7, 2026
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.
DanGould commentedon Oct 16, 2026
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.