Enforce the segwit assumptions on both sides of a payjoin - #78
Conversation
4118c86 to
7a6cdff
Compare
7a6cdff to
eb8f682
Compare
eb8f682 to
6f6c3bc
Compare
ValeraFinebits
left a comment
There was a problem hiding this comment.
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.
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.
6f6c3bc to
7ab9439
Compare
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