Skip to content

Feat/v2 receiver event split#1685

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

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

Conversation

@DanGould

@DanGould DanGould commented Jun 26, 2026

Copy link
Copy Markdown
Member

1) 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 DanGould added the bug Something isn't working label Jun 26, 2026
@coveralls

coveralls commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator

Coverage Report for CI Build 28364097711

Coverage increased (+0.2%) to 85.737%

Details

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

Uncovered Changes

File Changed Covered %
payjoin/src/core/receive/v2/session.rs 180 176 97.78%
payjoin/src/core/receive/common/mod.rs 164 161 98.17%
payjoin/src/core/receive/v2/mod.rs 18 15 83.33%
Total (4 files) 365 355 97.26%

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 15354
Covered Lines: 13164
Line Coverage: 85.74%
Coverage Strength: 356.75 hits per line

💛 - Coveralls

DanGould added 3 commits June 29, 2026 16:07
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. WorkingProposal 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
WorkingProposal; 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 WorkingProposal, 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]. Under
the 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, giving roughly 60 to 75 percent detection power per
run.

Thread a seeded rng through replace_receiver_outputs so the asserted
post-substitution state is fixed and the drain always moves off
owned_vouts[0]. Extract the shuffle body into
replace_receiver_outputs_with_rng, leaving the public method a wrapper
that still supplies thread_rng, so production behavior is unchanged.
Guard the seed with an assert_ne so a future rand draw that stops moving
the drain fails loudly instead of silently weakening the test.
@DanGould DanGould force-pushed the feat/v2-receiver-event-split branch 3 times, most recently from 46b837c to 92f1a19 Compare June 29, 2026 10:01
@DanGould DanGould marked this pull request as ready for review June 29, 2026 10:39
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