Skip to content

Enforce the segwit assumptions on both sides of a payjoin - #78

Merged
ValeraFinebits merged 4 commits into
ValeraFinebits:masterfrom
chavic:chavic/segwit-input-assumptions
Jul 29, 2026
Merged

Enforce the segwit assumptions on both sides of a payjoin#78
ValeraFinebits merged 4 commits into
ValeraFinebits:masterfrom
chavic:chavic/segwit-input-assumptions

Conversation

@chavic

@chavic chavic commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator

Two implicit segwit assumptions in the receiver flow, made explicit and enforced. Proposing this as policy — happy to adjust if you'd rather support legacy paths properly instead of excluding them.

Receiver coins. The input candidates are turned into PSBT inputs from the witness UTXO alone (no non_witness_utxo, redeem or witness scripts), which is only valid for natively segwit coins — a legacy or P2SH-wrapped coin produces an input the library rejects at weight prediction. Candidates are now filtered to P2WPKH/P2WSH/taproot at the availability service, which the checkout's "can we offer payjoin" signal also flows through: a legacy-funded wallet stops advertising a payjoin it cannot serve, instead of failing at contribution time.

Sender inputs. The settlement tracking pins the final transaction id derived on the receiver side — but a sender input without witness data re-signs through its scriptSig, so the broadcast transaction's id differs from the recorded one and reconciliation can never match it. The invoice would look unpaid despite a completed payjoin. Proposals spending non-witness inputs now close the receiver session at fallback-attach time, before anything is contributed, with a warning log; if the sender broadcasts their original, the invoice is still credited through core's own address tracking. The alternative — reconciling by settlement script over wallet transactions instead of by txid — would support legacy senders but is a materially bigger change; this draft takes the conservative route and documents it.

🤖 Generated with Claude Code

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

This policy makes sense to me: after this PR, payjoin receiving is effectively limited to native SegWit receiver coins and txid-stable sender inputs. One follow-up worth considering is surfacing that policy to the operator more explicitly.

GetConfirmedReceiverCoinsAsync() now filters the wallet’s confirmed coins down to P2WPKH/P2WSH/Taproot candidates. If the availability path only looks at that filtered result, then “wallet has coins, but they’re policy-ineligible” becomes indistinguishable from “wallet has no confirmed receiver inputs”, which could be misleading operationally.

It may be worth distinguishing:

  • no confirmed receiver coins
  • confirmed coins exist, but none are eligible under the native-SegWit policy

Since this method already has both the full and filtered sets in scope, that seems like a cheap follow-up improvement. Not blocking.

chavic added 4 commits July 28, 2026 16:06
Move the submodule from f4a6008a to the payjoin-1.0.0-rc.6 release
commit and adapt to the FFI surface changes:

- PjParseException is now UriParseException.
- BuildRecommended throws SenderInputException, and persisted sender
  errors are now SenderPersistedException.Transient/Fatal wrapping a
  SenderException, so the receiver-rejection handling matches
  SenderException.Response through the wrapper.
- IPayjoinProposal drops UtxosToBeLocked and gains
  ProposalTxidIsStable.

rc.5-format closed-session logs recorded scripts and witnesses in
Closed(Success); rc.6 records a txid, so such logs fail replay after
this bump. Every replay site already catches ReceiverReplayException
and removes the session, which is the same terminal outcome a closed
session gets on the next poller tick.
The receiver input path constructs PSBT inputs from the witness UTXO
alone, which is only valid for natively segwit coins, so candidate coins
are now filtered to P2WPKH, P2WSH and taproot before selection. This
also keeps checkout from advertising payjoin that a legacy-funded wallet
cannot actually serve, since availability follows the same filter.

On the sender side, an input without witness data re-signs through its
scriptSig, so the final transaction id differs from anything derived on
the receiver side and settlement reconciliation could never match it.
Such proposals now close the receiver session before anything is
contributed; a sender that broadcasts the original still pays the
invoice through the platform's own address tracking.
The sender-input check tested for missing witness data, but the txid
preimage covers the scriptSig, not the witness: nested SegWit inputs
finalize with a witness and a redeem-script push in the scriptSig, so
they passed the witness test while still rewriting the txid the
receiver pinned. Close the session when any fallback input finalizes
with a non-empty scriptSig instead, which admits exactly the native
SegWit inputs whose txid survives the sender's signing.
rust-payjoin now exposes proposal_txid_is_stable() on every receiver
typestate from MaybeInputsOwned onward, deciding txid stability on the
same scriptSig axis this plugin checked by scanning the fallback
transaction. Use the upstream predicate and drop the local scan.

The gate moves from fallback attachment to ContinueWithOutputsAsync,
the funnel every pre-contribution path passes through, so the decision
still lands before outputs are committed or inputs contributed. The
unit tests for the removed scan retire with it; upstream carries the
same legacy, nested, native, and mixed cases for the predicate.
@ValeraFinebits
ValeraFinebits merged commit bf6a5c1 into ValeraFinebits:master Jul 29, 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.

2 participants