Skip to content

Use rust-payjoin privacy-preserving receiver input selection - #59

Merged
ValeraFinebits merged 5 commits into
ValeraFinebits:masterfrom
chavic:chavic/input-selection-and-signing
Jul 6, 2026
Merged

ValeraFinebits merged 5 commits into
ValeraFinebits:masterfrom
chavic:chavic/input-selection-and-signing

Conversation

@chavic

@chavic chavic commented Jun 14, 2026 •

Copy link
Copy Markdown
Collaborator

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's try_preserving_privacy, so UIH2 avoidance is decided by the library instead of plugin heuristics. A new receiver wallet adapter maps wallet coins into FFI InputPairs and resolves the library's chosen input back to the ReceivedCoin we 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-payjoin master f4a6008a — the merge commit of #1654 — because the previously tracked fork branch does not contain it yet. Between the old pin (7e0f21ec) and f4a6008a, 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

  • Signature stripping/signing moved to Sign receiver inputs inside rust-payjoin finalize callback #64 (merged) and is no longer part of this PR.
  • This replaces the earlier random-shuffle approach per the review discussion: selection is now deterministic library-driven UIH2 avoidance rather than nondeterministic sampling, which also avoids the probing-surface concern raised there.

@DanGould DanGould left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@nothingmuch

nothingmuch commented Jun 16, 2026 •

Copy link
Copy Markdown

If you wanted to shuffle the candidates to avoid the smallest-first issue, I think a deterministic shuffle

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.

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

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 json_extract on the event data, so the only schema change would be adding an index on that expression, there's no need for an additional column, enumerating the events and by extension sessions which revealed a particular UTXO should be efficient with no major changes.

so that only a single input could be probed at a time from a malicious attacker.

Not if there are parallel sessions and the receiver honors its own expiry time.

@chavic

chavic commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator Author

@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

@chavic chavic changed the title Randomize receiver input selection and consolidate proposal signature stripping Use rust-payjoin privacy-preserving receiver input selection Jun 22, 2026
@chavic

chavic commented Jun 22, 2026 •

Copy link
Copy Markdown
Collaborator Author

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 InputPair previous outpoint through FFI, so it should stay blocked until that upstream/submodule update is available.

@chavic
chavic force-pushed the chavic/input-selection-and-signing branch from 733937c to 1972859 Compare July 2, 2026 15:37
@chavic
chavic requested review from ValeraFinebits and removed request for ValeraFinebits July 2, 2026 16:50

@ValeraFinebits ValeraFinebits left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@chavic

chavic commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator Author

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

@chavic

chavic commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator Author

@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 TryReserveContributedInput. So rebuilding mid-attempt would re-offer the input that just conflicted, and since try_preserving_privacy is deterministic for a given set it would pick it again. A rebuilding variant would need to carry an excluded-outpoints list across retries anyway, which is what the local removal already is, plus a wallet query per retry. The snapshot also keeps the loop bounded (every iteration shrinks the set) and the selection consistent within one attempt. Freshness comes from the tier above: a failed attempt returns to the poller, and the next tick re-enters with a freshly built candidate set, so a coin confirming mid-attempt waits at most one tick.

For the tests, the loop was calling the FFI WantsInputs methods directly, so none of it was reachable from unit tests (the binding types are sealed and their exceptions native-backed). I've moved those two calls behind a small seam (IPayjoinReceiverInputProposalOperations) that translates CoinSelectionException/InputContributionException into plugin exceptions at the boundary, and added the cases you listed against the real session store: happy path, reservation conflict -> reselect -> success, exhaustion when every candidate is reserved, contribution rejection -> reselect -> success, the "could not be mapped back" case, and library selection failure.

@chavic
chavic requested a review from ValeraFinebits July 3, 2026 12:47

@ValeraFinebits ValeraFinebits left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

chavic added 3 commits July 4, 2026 19:00
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.
@chavic
chavic force-pushed the chavic/input-selection-and-signing branch from f2282dd to 963c9f3 Compare July 4, 2026 17:10
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.
@chavic
chavic force-pushed the chavic/input-selection-and-signing branch from 963c9f3 to 938de57 Compare July 4, 2026 19:29
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.
@chavic

chavic commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator Author

@ValeraFinebits Thanks for the careful review. I've added some documentation along with the fixes

@chavic
chavic requested a review from ValeraFinebits July 5, 2026 19:29
@ValeraFinebits
ValeraFinebits merged commit d7da311 into ValeraFinebits:master Jul 6, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants