Skip to content

Feat/v2 receiver event split#1685

Draft
DanGould wants to merge 3 commits into
payjoin:masterfrom
DanGould:feat/v2-receiver-event-split
Draft

Feat/v2 receiver event split#1685
DanGould wants to merge 3 commits into
payjoin:masterfrom
DanGould:feat/v2-receiver-event-split

Conversation

@DanGould

Copy link
Copy Markdown
Member

What

Fix a silent fund-safety bug in the BIP 77 v2 receiver state machine. In the process this restructures the receiver's persisted event format so fewer bytes get persisted.

A v2 receiver session resumed (replayed) from its event log could diverge from the live state it was built from. In the worst case resuming at WantsInputs after an output substitution could silently misdirected the receiver's own funds to the sender, with no error anywhere.

AFAIU nobody is using output substitution, where this manifests, in production where this bug. And it's not something a malicious sender can trigger, just a random event.

I have NOT gone through the code and the tests yet, which is why this is draft, but I think the commit log at least carries rationale for the change.

Co-authored by Claude

Pull Request Checklist

Please confirm the following before requesting review:

DanGould added 3 commits June 27, 2026 00:57
The v2 receiver session is rebuilt by replaying an append-only event
log. The CommittedOutputs and CommittedInputs events persisted only a
summary, the output set and the contributed inputs, so a session
resumed at WantsInputs or WantsFeeRange did not match the live state:
the post-substitution change_vout, the randomly inserted receiver
inputs, and the change increment were all lost. Resumed at WantsInputs
after an output substitution, this can silently route the receiver's
own change to a sender output, which no check guards, since the sender
accepts any increase to one of its own outputs.

Split the shared receiver typestates into two parts. OriginalContext
holds the session invariant, the original PSBT and params, fixed once
the receiver outputs are identified. MutableProposal holds the
RNG-mutated remainder, the working PSBT, the change vout, and the
contributed inputs, none of which can be reconstructed from a summary.
WantsOutputs, WantsInputs, and WantsFeeRange now hold the context by
value alongside their mutable part. The commit events carry only the
MutableProposal; replay copies it verbatim and re-threads the invariant
from the predecessor state, so live, persisted, and replayed states are
identical by construction, without re-storing the original PSBT and
params (about 16 KB) in every commit event.

Route the additional_fee_contribution sanitization through a single
OriginalContext::new, used by both the live identify_receiver_outputs
path and the replay reconstruction. Replay previously rebuilt
WantsOutputs from raw params, so a session resumed at WantsOutputs whose
sender pointed the fee output at a receiver-owned vout threaded an
un-nulled parameter forward and could subtract the sender's
contribution from the receiver's own output.

The shared coin selection and fee code is reachable from BIP78 v1
receivers, so accessors keep v1 and the v2 fallback-tx impls reading
the original PSBT without exposing the split fields.

Add replay-vs-live equality tests at WantsInputs and WantsFeeRange that
drive a real substitution and input contribution, an owned-vout
provenance test resuming at WantsOutputs, and an exact sender-subsidy
fee test that catches a context and remainder PSBT swap.
The split commit events serialize only the MutableProposal, a strict
subset of the fields a fuller serialization would carry. The event enum
is externally tagged and does not deny unknown fields, so a payload that
also carries the invariant original PSBT and params deserializes
cleanly: the extra fields are ignored on read and the invariant is
re-threaded from the predecessor state.

Add a regression test that drives a real session, an output
substitution plus an input contribution, reconstructs such a richer
payload by splicing the invariant back into the two commit events, then
deserializes and replays it. It reaches the identical state, so a log
written with the extra fields needs no migration to read under the split
events.

This pins the subset relationship. A later change that adds
deny_unknown_fields or otherwise narrows the read path would fail here.
The replay-vs-live WantsInputs assertion only catches a lossy
reconstruction, one that pins change_vout to owned_vouts[0], when the
output substitution shuffles the drain output off owned_vouts[0]. With
unseeded thread_rng the drain stays put on a sizable fraction of runs,
so a buggy replay matches the live state by coincidence and the test
passes anyway: roughly 60 to 75% detection power per run.

Retry the substitution on a fresh event log, bounded to 32 tries, until
the live change_vout differs from owned_vouts[0], then run the replay
and assertion. The asserted state is now always one a lossy replay must
get wrong, so the regression is detected on every run.
@DanGould DanGould added the bug Something isn't working label Jun 26, 2026
@coveralls

Copy link
Copy Markdown
Collaborator

Coverage Report for CI Build 28253615925

Coverage increased (+0.2%) to 85.726%

Details

  • Coverage increased (+0.2%) from the base build.
  • Patch coverage: 10 uncovered changes across 3 files (342 of 352 lines covered, 97.16%).
  • No coverage regressions found.

Uncovered Changes

File Changed Covered %
payjoin/src/core/receive/v2/session.rs 179 175 97.77%
payjoin/src/core/receive/common/mod.rs 152 149 98.03%
payjoin/src/core/receive/v2/mod.rs 18 15 83.33%
Total (4 files) 352 342 97.16%

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 15343
Covered Lines: 13153
Line Coverage: 85.73%
Coverage Strength: 356.96 hits per line

💛 - Coveralls

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants