Skip to content

Support multi-output UIH selection - #1837

Closed
Jolah1 wants to merge 2 commits into
payjoin:masterfrom
Jolah1:support-multi-output-uih
Closed

Support multi-output UIH selection#1837
Jolah1 wants to merge 2 commits into
payjoin:masterfrom
Jolah1:support-multi-output-uih

Conversation

@Jolah1

@Jolah1 Jolah1 commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Closes #551.

Two commits, split after review so the correctness fix and the design decision can be judged
separately. Each passes CI on its own.

1. Compare UIH against post-contribution outputs

avoid_uih documents its minimum output as post-contribution, but computed the minimum over every
output at its pre-contribution value and only then folded the receiver's output back in. When
the receiver's output is the strictly smallest, its pre-contribution value survives the outer min
and the check runs against an amount the transaction never has, accepting candidates that do not
avoid UIH2.

Reachable in the existing 2-output path: receiver output 1 sat, sender output 4,000,000 sat,
candidate 3,000,000 sat -> post-contribution min_out = 3,000,001 vs min_in = 3,000,000, yet the
check passes on master. No existing expectation changes, since the 2-output test vector pays the
receiver its largest output (95,983,068 vs 2,000,000) — which is why the buggy branch had no
coverage.

2. Support multi-output UIH selection

Relax the != 2 guard to < 2.

The condition min_in > min_out does not depend on the output count. An analyst assuming output o
is the spender's change treats input i as unnecessary when i <= o, because dropping i would
still fund every other output; the optimal change heuristic points that at the smallest output. Only
the single-output case is genuinely unsupported — with no change output there is nothing for the
heuristic to identify.

More outputs give an analyst more change hypotheses, and this check only defeats the UIH1 one, so it
is weaker at n>2. It is never weaker than the status quo: today those transactions fall through to
select_first_candidate with no privacy check at all. Full reasoning is recorded on avoid_uih.

Against #551's two motivations

Anonymity set (n-input, m-output). Covered. avoid_uih_supports_three_outputs plus
avoid_uih_supports_multiple_inputs_and_outputs, the latter with two existing inputs and three
outputs, where the binding minimum input comes from an existing input rather than the candidate.

Multiparty / NS1R. Not exercisable here, and I would rather say so than imply otherwise. NS1R
was removed from master in 6b87adc (backed up to ns1r-master-backup, see #922), so there is no
multiparty receiver path to route through try_preserving_privacy or to test against. Relaxing the
guard is the enabling change the issue asks for — when NS1R returns it inherits UIH-aware selection
instead of select_first_candidate — but the multiparty half cannot be demonstrated until then.

I did not take the issue's "relax only in the multi-party context" alternative: relaxing generally is
a superset, and a multiparty-only gate would currently have no call site.

Output splitting, candidate scoring, and ranking against non-UIH1 change hypotheses stay out of
scope, as proposed when claiming the issue.

Tests

  • cargo test -p payjoin --all-features (on each commit)
  • cargo clippy -p payjoin --all-targets --all-features -- -D warnings (on each commit)
  • cargo fmt --all -- --check
  • codespell

co-authored by Codex and Claude

@Jolah1
Jolah1 marked this pull request as ready for review August 24, 2026 13:58
@coveralls

coveralls commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

Coverage Report for CI Build 33716840747

Coverage increased (+0.05%) to 86.688%

Details

  • Coverage increased (+0.05%) from the base build.
  • Patch coverage: 1 uncovered change across 1 file (56 of 57 lines covered, 98.25%).
  • No coverage regressions found.

Uncovered Changes

File Changed Covered %
payjoin/src/core/receive/error.rs 1 0 0.0%
Total (2 files) 57 56 98.25%

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 16527
Covered Lines: 14327
Line Coverage: 86.69%
Coverage Strength: 342.38 hits per line

💛 - Coveralls

@xstoicunicornx xstoicunicornx left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

When considering supporting UIH selection for 2+ outputs there is more to consider than just un-gating the number of outputs allowed, which is largely what looks like was done here.

Unnecessary Input Heuristic (UIH) is fairly straight forward to avoid with a 2 output transaction, which is currently the only scenario that avoid_uih accounts for. It chooses an input where the minimum of all inputs in the tx are larger than the minimum of all outputs in the tx, which creates a transaction where both inputs look like they are required to create the larger output value and avoids UIH. This calculation explicitly relies on the assumption that there are 2 outputs, as finding the minimum of the outputs implies the value of the other (maximum) output and consequently that both inputs were required to create the larger output (hence avoiding UIH). This assumption breaks at 3 outputs.

Admittedly I have not given much thought on how to expand this to >2 outputs myself, however I do expect any PR trying to address this to have robust rationales on the design decisions made.

Comment on lines +290 to +292
.enumerate()
.filter(|(vout, _)| *vout != self.proposal.change_vout)
.map(|(_, output)| output.value)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Why are we filtering out the change output? Doesn't this mess up our existing 2 output avoid_uih calculation? How does this help with >2 outputs?

@Jolah1 Jolah1 Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

change_vout is the receiver's own output, not the sender's change — contribute_inputs bumps it
with output[change_vout].value += change_amount. The name misleads.

The filter is a separate bug fix, not part of un-gating the output count. avoid_uih documents a
post-contribution minimum but took the min over all outputs at pre-contribution values, so when
the receiver's output is the smallest, min(min_out, prior + candidate) discards the bumped term.
On 2 outputs: receiver 1 sat, sender 4,000,000, candidate 3,000,000 -> the real min_out is
3,000,001 against min_in 3,000,000, yet the check passes on master.

It doesn't break the 2-output path: the test vector pays the receiver its largest output
(95,983,068 vs 2,000,000), so nothing existing moves — reverting only the filter flips exactly one
test, the new one. And on its own it doesn't help >2 outputs either; the 3-output test passes either
way. Hence two commits now.

@Jolah1
Jolah1 force-pushed the support-multi-output-uih branch from 484eca0 to d4919d9 Compare September 3, 2026 04:35
@Jolah1

Jolah1 commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

Fair — the PR didn't make the argument. Two commits now, with the reasoning recorded on avoid_uih.

Your derivation is right for 2-in/2-out, but the code never computes it; it only evaluates
min_in > min_out. That condition is output-count independent: if an analyst assumes output o is
the change, input i was unnecessary iff sum(I) - i >= sum(O) - o + f, which with
sum(I) = sum(O) + f reduces to i <= o. UIH1 sets o = min(O), giving min(I) > min(O).

What does change at 3+ outputs is the number of change hypotheses available, and this check only
defeats the UIH1 one. That isn't new at 2 outputs though — outputs 1,000/10,000 with inputs 2,000
and 9,000+f pass and are still flagged under "change = 10,000"; UIH1 just discards that hypothesis.
More outputs soften UIH1's confidence rather than break the identity the code computes.

Main argument for landing it: it can't make anything worse. A 3-output proposal currently falls
through to select_first_candidate with no check at all. After this it either gets a candidate that
also satisfies min_in > min_out, or the same first candidate.

@Jolah1
Jolah1 force-pushed the support-multi-output-uih branch from d4919d9 to 5d31dc8 Compare September 3, 2026 04:52
avoid_uih documents its minimum output as a post-contribution value,
but computed the minimum over every output at its pre-contribution
value and only then folded the receiver's output back in increased by
the candidate. Whenever the receiver's output is the strictly smallest,
its pre-contribution value survives the outer minimum, so the check
runs against an amount lower than the transaction will actually have
and accepts candidates that do not avoid UIH2.

With a receiver output of 1 sat, a sender output of 4,000,000 sat and a
3,000,000 sat candidate, the post-contribution minimum output is
3,000,001 sat against a 3,000,000 sat minimum input, yet the check
passes.

Exclude the receiver's output from the minimum so the only value it
contributes is the post-contribution one. The two-output test vector
pays the receiver its largest output, so this leaves every existing
expectation unchanged and the buggy branch had no coverage at all.
avoid_uih rejected any transaction without exactly two outputs, so
batched senders, and receivers who split their own output, fell back to
the first candidate with no privacy check at all.

The check does not depend on the output count. An analyst who assumes
output o is the spender's change treats input i as unnecessary when
i <= o, since dropping i would still fund every other output. The
optimal change heuristic points that assumption at the smallest output,
so a transaction avoids UIH2 exactly when its smallest input exceeds
its smallest output, for any number of outputs. Only the single-output
case is genuinely unsupported: with no change output there is nothing
for the heuristic to identify.

More outputs do give an analyst more change hypotheses to try, and this
check only defeats the one the optimal change heuristic picks. That is
weaker than the two-output case, but never weaker than returning the
first candidate unexamined, which is what these transactions get today.

Relax the guard to fewer than two outputs and record the reasoning on
avoid_uih. Cover both the three-output case and the n-input, m-output
shape that motivates the change, where the minimum input comes from an
existing input rather than the candidate.
@Jolah1
Jolah1 force-pushed the support-multi-output-uih branch from 5d31dc8 to 4c80781 Compare September 3, 2026 04:55
@spacebear21

Copy link
Copy Markdown
Collaborator

Closing as unresearched slop

@spacebear21 spacebear21 closed this Sep 3, 2026
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.

Try preserving privacy for transactions greater than 2 outputs

4 participants