Skip to content

Add kind accessors to receive candidate errors - #1717

Closed
chavic wants to merge 1 commit into
payjoin:masterfrom
chavic:chavic/receive-error-kinds
Closed

Add kind accessors to receive candidate errors#1717
chavic wants to merge 1 commit into
payjoin:masterfrom
chavic:chavic/receive-error-kinds

Conversation

@chavic

@chavic chavic commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator

Related to #1422.

CoinSelectionError and InputContributionError are opaque because their variants may still change, but that leaves callers of try_preserving_privacy / contribute_inputs unable to distinguish failures that could succeed with a different candidate set from failures no candidate change can fix, short of matching display strings.

Field report: the BTCPay Server payjoin plugin drives contribution with a candidate-snapshot loop, snapshot spendable coins, let selection pick, on failure remove the offending candidate and retry with the reduced set until success or exhaustion. Because the errors are opaque, the loop has to treat every failure as maybe-retryable, and burns attempts on failures like ValueTooLow where removing candidates can never help.

This adds kind() returning a #[non_exhaustive] kind enum on both errors: callers get a stable, matchable classification while the error types stay free to evolve, a new variant only needs a kind category, and consumers must already handle unknown kinds conservatively. The accessors are mirrored in payjoin-ffi with an explicit Other catch-all; today these errors cross the FFI as display-only objects.

Disclosure: co-authored by Claude Code

Pull Request Checklist

Please confirm the following before requesting review:

CoinSelectionError and InputContributionError are opaque because
their variants may change, but that leaves callers of
try_preserving_privacy and contribute_inputs unable to distinguish
failures that could succeed with a different candidate set from
failures no candidate change can fix, short of matching display
strings. A wallet driving selection with a retry loop burns
attempts on the second class.

Add kind() returning a non_exhaustive kind enum on both errors:
callers get a stable, matchable classification while the error
types stay free to evolve, since new variants only require a new
kind category and consumers must already handle unknown kinds.
Mirror the accessors in payjoin-ffi with an explicit Other
catch-all, giving bindings the same signal; today these errors
cross the FFI as display-only objects.

Related to payjoin#1422.
@coveralls

Copy link
Copy Markdown
Collaborator

Coverage Report for CI Build 29009356795

Coverage increased (+0.03%) to 85.893%

Details

  • Coverage increased (+0.03%) from the base build.
  • Patch coverage: 28 of 28 lines across 1 file are fully covered (100%).
  • No coverage regressions found.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 15581
Covered Lines: 13383
Line Coverage: 85.89%
Coverage Strength: 349.95 hits per line

💛 - Coveralls

@chavic
chavic requested a review from DanGould July 9, 2026 11:09

@DanGould DanGould left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

My request is the same as your #1654. Please demonstrate the need in the downstream code, or at least a toy integration, to prove that the shape of the PR is appropriate for your feature request.

Second, why re-create the InternalCoinSelectionError rather than promote those variants to live inside CoinSelectionError as a transparent pub enum that just becomes #[non_exhaustive]. I'm not sure I see a reason that this one needs to remain an Intenal error with opaque variants because they're all rust-payjoin defined, unlike others which are kept internal to encapsulate error variants produced by dependencies.

@DanGould DanGould added the api label Jul 14, 2026
@chavic

chavic commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator Author

The current downstream solution to the missing info is to assume every contribution failure is candidate-specific, drop the coin and retry with the rest (PayjoinReceiverInputSelector.cs:111-115):

catch (PayjoinReceiverInputContributionException ex)
{
    contributionFailures.Add($"candidate '{selectedCoinOutPoint}' rejected: {ex.Message}");
    candidates.Remove(selected);   // assume the failure was candidate-specific, retry with the rest
}

That's the right move for DuplicateInput (which BTCPay hits through cross-session input reservations) and the wrong one for ValueTooLow, which is a property of the whole proposal: retrying with fewer coins can never succeed, so a wallet with N candidates burns N doomed selection+contribution round trips and logs N "candidate rejected" lines for one session-level condition.

The exception reaches us as a Display string only (PayjoinReceiverInputProposalOperations.cs:49-52), so the only alternative to guessing is matching on the message text.

Selection has the same problem one layer up (PayjoinReceiverInputSelector.cs:81-85): we break identically on all three variants, but Empty is a transient wallet state, UnsupportedOutputLength is a permanent fact of the sender's proposal, and NotFound is a policy fork (contribute nothing vs fall back). These land in an operator-facing failure message BTCPay can't classify.

@spacebear21 spacebear21 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.

Concept NACK, the premise this PR rests on seems flawed.:

  • try_preserving_privacy can only ever return Empty. avoid_uih is the sole producer of NotFound/UnsupportedOutputLength, and its error is unconditionally swallowed by the .or_else(select_first_candidate) fallback, so a non-empty candidate list always yields Ok and an empty one always yields Empty. The BTCPay implemenation already guards candidates.Count == 0 before its loop, which means that CoinSelectionException catch is dead code there too.
  • contribute_inputs can only raise ValueTooLow if the caller contributed additional outputs with replace_receiver_outputs, which BTCPay (or any live implementation AFAIK) does not do.

UnsupportedOutputLength and DuplicateInput are the variants I least want to expose and freeze in the public API. Both of those are implementation details functions whose internals could change (e.g. avoid_uih could conceivably support >2 outputs, or contribute_inputs could use some kind of BTreeMap to bypass the duplicate inputs issue entirely). #[non_exhaustive] doesn't save us here, it protects additions but not removals.

So for BTCPay's actual flow, every reachable failure collapses to a single variant per method:

  • Selection: only Empty, which they already guard before the loop, so the catch is dead.
  • Contribution: only DuplicateInput, since ValueTooLow needs an output-value increase they don't do. And DuplicateInput is always the candidate-specific, remove-and-retry case, which their loop already handles correctly.

@chavic

chavic commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator Author

@spacebear21 That makes sense; I'd assumed some errors could be fatal beyond the currently selected inputs. Looking at the method calls, I think you're right. I've verified select_first_candidate makes Empty the only observable selection error, ValueTooLow needs replace_receiver_outputs, which we don't call, and our CoinSelectionException catch is now probably dead code behind the empty guard. I'll fix the BTCPay side instead of the API, and probably close this.

@chavic chavic closed this Jul 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants