Repository navigation
Use rust-payjoin privacy-preserving receiver input selection - #59
ValeraFinebits merged 5 commits into
Conversation
DanGould
left a comment
There was a problem hiding this comment.
The issue with non-deterministic random selection was recently discussed again in this pr:
Non-deterministic selection makes a receiver more vulnerable to probing attacks than the status quo because an adversarial sender using multiple inputs may request payjoin sessions it does not intend to complete where the receiver reveals their UTXOs as inputs in the proposal they return. The privacy risks presented by probing (revealing for certain the actual contents of the wallet) that this would create are more severe than the risk created from producing UIH2-conforming joins. And this UIH2 (still probabilistic, much less certain than a revealed input) issue should may appear only in an alpha release before we fix FFI, if it gets published at all, since you have already outlined a mitigation using the core rust-payjoin library.
If you wanted to shuffle the candidates to avoid the smallest-first issue, I think a deterministic shuffle, and perhaps (thinking out loud, don't hold me to this as a suggestion) a table to track and continue to offer UTXOs already offered would be the most appropriate manifestation of the budding idea presented here since at least the random choice would be repeatable so that only a single input could be probed at a time from a malicious attacker.
For these reasons, I think the current implementation of coin selection this replaces is really the best we can do before addressing the FFI bindings concern and I have to recommend against this change.
FFI hands back an opaque InputPair with no outpoint accessor and WantsInputs exposes no PSBT, so the selected input can't be mapped back to the ReceivedCoin we need for reservation and signing.
I very much want to see better heuristic avoidance in our core coin selection in payjoin like you propose here, but for now, I don't think this repo is the place to run that experiment since the risks I perceive outweigh the benefits and there seems to be enough meat to ship an alpha without it.
If there is a logical place to store persistent randomness that is shared across sessions, all candidate inputs could be deterministically shuffled according to this secret. That would presumably need another table with just a single row, or perhaps could use a machine bound secret. This would make allow the sorting agnostic to wallets that don't return things in a deterministic order. Deterministic shuffling not seeded by secret randomness is problematic. Sorting lexicographically leaks the size of the wallet statistically based on how small or large the revealed value is (edit: not the value in satoshis, the sort key, presumably a hash of the outpoint). Sorting by age is arguably worse as it enumerates the wallet's cluster in order. In general, UTXO probing is a strong clustering heuristic based on private information and should be treated as such.
Yes. If the list of candidate coins overlaps with coins that were used in previous sessions that never materialized into a payjoin, those coins could be favored, randomly selecting from what was already disclosed before falling back to revealing a new coin. The session persistence design was intended make this relatively straightforward. In SQLite this can even be done by
Not if there are parallel sessions and the receiver honors its own expiry time. |
|
@DanGould @nothingmuch 🤔 thanks for the insight, looking into a way to fully use try privacy preserving so this flow is more reliant on how the library decides UIH2 avoidance should be done, or a reasonable compromise |
|
I've narrowed this PR branch. The intended scope is now input selection only. Signature stripping/signing has moved to #64. This branch assumes rust-payjoin will expose the selected |
733937c to
1972859
Compare
ValeraFinebits
left a comment
There was a problem hiding this comment.
I like the overall direction here. One thing I'd like you to think through a bit more: should we rebuild the candidate set on each retry instead of snapshotting once and locally removing failed entries? I'm not sure it needs to change, but I do think the chosen retry model should be explicit and justified. Before merging, I'd also want focused tests for the new flow: happy path, reservation conflict -> reselect -> success, repeated conflicts/rejections until exhaustion, and the "selected input could not be mapped back" case.
There's a two-tier loop to cover the first concern. I'll make some changes to make it more visible. The test gap is genuine, worth covering |
|
@ValeraFinebits I've kept the snapshot + local removal model and documented it in the loop. The wallet view we build candidates from doesn't exclude coins reserved by concurrent sessions, reservations only surface at For the tests, the loop was calling the FFI |
ValeraFinebits
left a comment
There was a problem hiding this comment.
Overall the direction here looks good. There are just a few implementation assumptions I think are worth double-checking before merge.
First, I may be missing the ownership model of the generated rust-payjoin FFI types, but this path now builds Payjoin.InputPair instances into the candidate set and keeps them alive across selection retries. Could you confirm whether these are purely managed wrappers, or whether they hold native state that needs explicit disposal / ownership handling on success, conflict, rejection, or exceptional exits?
Second, I'd like to double-check how the selector-level retry model composes with the higher-level session lifecycle. The comment in PayjoinReceiverInputSelector says freshness comes from re-entering on a later poll tick with a rebuilt candidate set. Could you confirm that transient contribution failures actually leave the session in a state where that later retry can happen, rather than being terminated immediately higher up in PayjoinReceiverSessionProcessor?
Third, the new tests are focused and useful, but the plugin <-> rust-payjoin seam still feels like the riskiest boundary. In particular, it seems worth explicitly validating wallet-coin -> InputPair mapping, selected-input roundtrip back to the wallet coin, and reservation-race / retry behavior.
None of these necessarily block the approach, but they seem like the right places to validate the implementation assumptions.
Point the rust-payjoin submodule at upstream payjoin/rust-payjoin master commit f4a6008a, which includes the merged InputPair outpoint accessor (payjoin/rust-payjoin#1654). The previously tracked fork branch does not contain that change yet. Between the old pin and f4a6008a upstream upgraded uniffi to 0.31.2, which renamed the generated C# record accessors from camelCase fields to PascalCase properties, so the plugin call sites are renamed to match.
Replace the deterministic smallest-first receiver coin choice with rust-payjoin's try_preserving_privacy, so UIH2 avoidance is decided by the library instead of plugin heuristics. A receiver wallet adapter maps wallet coins to FFI InputPairs and resolves the selected input back to its ReceivedCoin through the new InputPair outpoint accessor. When a privacy-selected coin loses its reservation race to another session, the candidate is removed and selection is retried over the remaining coins.
Wrap the rust-payjoin proposal calls behind an injectable seam so the selection and retry logic stays free of FFI types, translating the library's selection and contribution errors into plugin exceptions at that boundary. Document the retry model where it lives: candidates are snapshotted once per attempt and failed entries removed locally, because the wallet view does not exclude coins reserved by concurrent sessions and rebuilding mid-attempt would re-offer the input that just failed; each poll tick re-enters with a fresh set. Adds focused tests for the contribution flow: successful selection, reservation conflict with reselection, exhaustion when every candidate is reserved, contribution rejection with reselection, unmappable selection, and library selection failure.
f2282dd to
963c9f3
Compare
The FFI input pairs built for the candidate set hold native state and were only released by finalizers; the attempt now disposes every candidate once it completes, on success and failure alike, since the library clones what it keeps when an input is contributed. Adds boundary tests that exercise the wallet-coin to InputPair mapping and the selected-input roundtrip with real library objects, alongside the existing reservation-race coverage against the real session store.
963c9f3 to
938de57
Compare
Transient reservation races resolve inside the attempt by reselecting from the remaining candidates, so an exhausted attempt means nothing was contributable. Keeping the session alive to retry later would let it reserve freshly confirmed coins for a sender that already gave up, starving future payjoins until the monitoring deadline - the concurrency integration tests assert exactly this property. Ending the session mirrors how the v1 endpoint answers unavailability, and the sender's original transaction still pays the invoice.
|
@ValeraFinebits Thanks for the careful review. I've added some documentation along with the fixes |
Selection always contributed the smallest confirmed UTXO, and doing that on every payjoin systematically reproduces the Unnecessary Input Heuristic 2 fingerprint — the smallest input sitting below the smallest output — which advertises that the transaction is a payjoin, the opposite of the point.
Now that the FFI exposes the selected
InputPair's outpoint (payjoin/rust-payjoin#1654, merged), the receiver can delegate selection to rust-payjoin'stry_preserving_privacy, so UIH2 avoidance is decided by the library instead of plugin heuristics. A new receiver wallet adapter maps wallet coins into FFIInputPairs and resolves the library's chosen input back to theReceivedCoinwe need for reservation and signing. When a privacy-selected coin loses its reservation race to another session, that candidate is removed and selection reruns over the remaining coins.rust-payjoin submodule update
The temporary fork pin is gone. The submodule now points at upstream
payjoin/rust-payjoinmasterf4a6008a— the merge commit of #1654 — because the previously tracked fork branch does not contain it yet. Between the old pin (7e0f21ec) andf4a6008a, upstream upgraded uniffi to 0.31.2, which changed the generated C# record accessors from camelCase fields to PascalCase properties (.request→.Request,.inner→.Inner,.psbtBase64→.PsbtBase64, …). The first commit therefore also carries that mechanical rename across the receiver processors, the test-payment sender, and test utilities. Happy to split that commit into its own "update rust-payjoin" PR if you'd rather review it separately.scope notes