Skip to content

Expose proposal txid stability on receiver states#1718

Open
chavic wants to merge 3 commits into
payjoin:masterfrom
chavic:chavic/proposal-txid-stability
Open

Expose proposal txid stability on receiver states#1718
chavic wants to merge 3 commits into
payjoin:masterfrom
chavic:chavic/proposal-txid-stability

Conversation

@chavic

@chavic chavic commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator

This is the 1.0-sized companion to the problem #1692 solves properly. #1692 makes non-SegWit sessions monitorable by classifying spends of the contested outpoints; this PR only makes the existing limitation visible at the right moment, without touching any frozen type. It's intended as the machine-readable form of the disclaimer suggested as the 1.0 mitigation in #1692 (document that check_for_transaction doesn't properly support non-SegWit inputs).

Today, Monitor::check_for_transaction knows the proposal's transaction ID cannot be tracked when the sender contributed non-SegWit inputs, and concludes the session as PayjoinProposalSent without checking the network. That knowledge arrives too late for receivers that do settlement accounting. A receiver that credits payments (a merchant server, an exchange) typically records the expected final transaction ID when it hands out the proposal — for SegWit-only sender inputs, unsigned_tx.compute_txid() stays valid once the sender re-signs, so pinning it enables exact reconciliation later. With any non-SegWit sender input the pinned ID is silently wrong: the sender's final signature rewrites the script_sig and changes the ID. Nothing warns the host at the moment the expectation is recorded; the first signal is the blind conclude at Monitor time.

This exposes the predicate Monitor already uses as proposal_txid_is_stable() on every state that holds the fallback transaction (the HasFallbackTx states), refactors check_for_transaction to use it (behavior unchanged), and mirrors it in payjoin-ffi. Receivers can then pick a policy as soon as the original payload is checked: decline to contribute and let the fallback pay, or reconcile that session by script/outpoint instead of by transaction ID. The BTCPay Server payjoin plugin currently implements the conservative policy by parsing the original transaction host-side (ValeraFinebits/btcpayserver-payjoin-plugin#78); this accessor lets any wallet make the same call without re-deriving protocol internals.

Relationship to #1692: strictly additive and shape-neutral, so it neither constrains nor competes with contested-outpoint classification. If/when that lands, this accessor degrades gracefully from "necessary policy input" to "convenience": hosts that reconcile by pinned transaction ID still want to know stability up front, while hosts on classify() won't need it for monitoring at all.

Disclosure: co-authored by Claude Code

Pull Request Checklist

Please confirm the following before requesting review:

@coveralls

coveralls commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator

Coverage Report for CI Build 29586661698

Coverage increased (+0.09%) to 86.279%

Details

  • Coverage increased (+0.09%) from the base build.
  • Patch coverage: 1 uncovered change across 1 file (120 of 121 lines covered, 99.17%).
  • No coverage regressions found.

Uncovered Changes

File Changed Covered %
payjoin/src/core/receive/v2/mod.rs 121 120 99.17%

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 16077
Covered Lines: 13871
Line Coverage: 86.28%
Coverage Strength: 342.85 hits per line

💛 - Coveralls

@chavic

chavic commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator Author

The current downstream solution to the missing signal, from the BTCPay Server payjoin plugin
(a settlement-accounting receiver over payjoin-ffi):

BTCPay pins the proposal's txid when the proposal is handed out
(PayjoinReceiverProposalFinalizer.cs:77-78)
and later reconciles the invoice by fetching exactly that txid from the wallet
(PayjoinAccountingPaymentService.cs:91).
With any non-SegWit sender input that pinned id is silently wrong — the sender's final signature
rewrites the script_sig and changes the txid — and nothing says so at the moment the expectation
is recorded; the first signal is check_for_transaction blind-concluding PayjoinProposalSent
at Monitor time, long after the accounting row was written.

So the plugin re-derives the predicate host-side: it extracts the fallback transaction and checks
the inputs' witnesses itself, closing the session as policy when any input lacks witness data
(PayjoinReceiverSessionProcessor.cs:487-490,
applied at L443):

internal static bool HasNonWitnessInput(Transaction transaction)
{
    return transaction.Inputs.Any(input => input.WitScript is null || input.WitScript == WitScript.Empty);
}

That is a host-side copy of the exact check Monitor::check_for_transaction already performs
internally before giving up on tracking. proposal_txid_is_stable() replaces the copy with one
library call at the point where the receiver actually decides — pin the txid, reconcile by
script/outpoint instead, or decline the session.

@benalleng benalleng added the receive receiving payjoin label Jul 16, 2026

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

Concept ACK. Seems like it fits the hole BTCPayServer needs. Thank you for pointing us to the BTCPay integration points! Makes it a lot easier to review.

I do wonder if the other integrations will be able to make use of it too.

One correctness issue:

Comment on lines +461 to 480
/// Whether the payjoin proposal's transaction ID is knowable in advance.
///
/// If every sender input is SegWit, the sender's final signatures live in
/// the witness, so the transaction ID computed from the unsigned proposal
/// remains valid once the sender re-signs — the receiver can record it
/// (e.g. to reconcile an incoming payment) and monitor the network for it.
///
/// If any sender input is non-SegWit, the sender's final signature
/// rewrites that input's script_sig, which changes the transaction ID.
/// Any transaction ID derived from the proposal before the sender signs
/// will never appear on the network, and
/// [`Receiver<Monitor>::check_for_transaction`] concludes the session
/// without monitoring. Receivers that track payments by transaction ID
/// can use this to choose a policy up front: decline to contribute and
/// let the fallback pay, or reconcile that session by script instead of
/// by transaction ID.
pub fn proposal_txid_is_stable(&self) -> bool {
!self.state.fallback_tx().input.iter().any(|txin| txin.witness.is_empty())
}
}

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.

P2SH-P2WSH has a non-empty witness but its redeemScript push still lands in scriptSig, so it'd cause this function to return the wrong value.

I think input.iter().all(|txin| txin.script_sig.is_empty()) covers all of the cases you want.

@DanGould

DanGould commented Jul 17, 2026

Copy link
Copy Markdown
Member

Funny here

// The sender's inputs are SegWit, so the proposal's transaction ID is monitorable.
assert!(monitor.proposal_txid_is_stable());
the bip78 ORIGINAL_PSBT is p2sh-p2wpkh so the sender's inputs being segwit is irrelevant to txid stability if it's wrapped. So I think this assertion is actually wrong, too. Fix here DanGould@6b50214

@chavic

chavic commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator Author

Concept ACK. Seems like it fits the hole BTCPayServer needs. Thank you for pointing us to the BTCPay integration points! Makes it a lot easier to review.

I do wonder if the other integrations will be able to make use of it too.

I assume it applies to any receiver that records or watches payments by transaction ID

@chavic

chavic commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator Author

Seems dependencies changed beneath us; the Windows failure is tokio 1.53.0. The C # workflow builds without a lockfile, resolves to whatever seems latest. Actually, seems all the other language workflows are floating too.

@DanGould worth a separate PR?

@chavic

chavic commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator Author

The Windows csharp failure here is unrelated to this PR: the csharp CI resolves dependencies fresh on every run and picked up tokio 1.53.0, which uses Once::wait (stabilized in 1.86) in its Windows-only signal code on the 1.85 toolchain pin. Fix proposed in #1748.

chavic and others added 3 commits July 17, 2026 15:59
Monitor::check_for_transaction already knows the proposal's
transaction ID cannot be tracked when the sender contributed
non-SegWit inputs, and concludes the session as PayjoinProposalSent
without checking the network. That knowledge arrives too late for
receivers that record the expected transaction ID when handing out
the proposal, e.g. to reconcile an incoming payment: with a
non-SegWit sender input the recorded ID is silently wrong, because
the sender's final signature rewrites the script_sig and changes
the ID.

Expose the predicate Monitor uses as proposal_txid_is_stable() on
every state that holds the fallback transaction, and mirror it in
payjoin-ffi. Receivers can now pick a policy as soon as the
original payload is known: decline to contribute and let the
fallback pay, or reconcile that session by script instead of by
transaction ID.
proposal_txid_is_stable() tested the wrong axis: it looked for an
empty witness, but what changes the transaction ID is the script_sig.
A P2SH-wrapped SegWit input (P2SH-P2WPKH/P2SH-P2WSH) finalizes with a
non-empty witness AND a script_sig carrying the redeem script push,
and the script_sig is part of the txid preimage. The witness test
called such inputs stable, so receivers would record and monitor a
transaction ID that can never appear on the network.

Test for empty script_sigs instead: an input whose final script_sig
is empty is exactly one whose signature data lives entirely in the
witness. This also corrects Receiver<Monitor>::check_for_transaction,
which previously polled forever for nested SegWit senders and now
concludes the session as PayjoinProposalSent, matching the existing
legacy-input behavior.

The canonical BIP78 test vectors spend P2SH-P2WPKH, so the monitor
typestate test was itself asserting the bug when it called those
fixtures stable. Give it a native P2WPKH fixture pair for the
monitorable paths, and add a nested SegWit regression test proving
the txid changes on finalization while the witness is non-empty.
The exported accessor's documentation still described the witness-based
predicate. Match the core method: stability requires every sender input
to finalize with an empty script_sig, which nested SegWit inputs do
not.
@chavic
chavic force-pushed the chavic/proposal-txid-stability branch from e9638ff to e3eec67 Compare July 17, 2026 14:07
@chavic

chavic commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator Author

Just forced push with lease

@chavic
chavic requested a review from DanGould July 17, 2026 15:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

receive receiving payjoin

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants