rust-payjoin icon indicating copy to clipboard operation
rust-payjoin copied to clipboard

Decoupling receiver typestates with common abstractions

Open arminsabouri opened this issue 4 months ago • 1 comments

Currently, our receiver typestates enforce a strict hierarchy where V2 typestates wrap V1 typestates. This design reduces code duplication, but it's becoming a liability as the V2 API evolves.

For example:

  • We must map to V1-specific errors that don’t make sense in the V2 context.
  • Unit tests require excessive boilerplate. For example, testing V2 fee application logic means instantiating a full receiver state machine and progressing it step by step just to reach the fee application state.
  • It is also bloating our V2 session events in some cases. More on this below
let unchecked = v1::test::unchecked_proposal_from_test_vector();
let wants_outputs = unchecked
    .assume_interactive_receiver()
    .check_inputs_not_owned(&mut |_| Ok(false))
    .expect("No inputs should be owned")
    .check_no_inputs_seen_before(&mut |_| Ok(false))
    .expect("No inputs should be seen before")
    .identify_receiver_outputs(&mut |_| Ok(true))
    .expect("Receiver output should be identified");

let wants_inputs = wants_outputs.commit_outputs();
let v1_wants_fee_range = wants_inputs.commit_inputs();

Toward a Decoupled Design

We need to introduce shared abstractions that cleanly separate session logic from typestate progression. An initial attempt is available here.

For the UncheckedProposal to OutputsUnknown states, we can introduce a Proposal { psbt, params } struct that implements shared checks -- such as input seen before or broadcast suitability -- without tying them to V1/V2 typestates. Typestates still encode state machine semantics but we can remove the hierarchy. Note that the shared abstractions are session and protocol version agnostic.

We could apply this pattern incrementally to other typestates. i.e Replace inner V1 states with independent session agnostic structs -- unless there is a compelling reason to wrap v1?

Lastly, this decoupling also reduces the payload size for V2 session events. Recall, session events represent new information discovered during state progression. We shouldn't need to clone the entire PSBT and params for read-only receiver check states.

A WIP PoC is available here. We can incrementally migrate to this design to ease review, but full decoupling should be completed before a release.

Related ticket: #924 This ticket was created out of this discussion

arminsabouri avatar Aug 08 '25 16:08 arminsabouri

  • [x] decouple typestates
  • [ ] decouple errors
  • [x] minimize SessionEvent variants

DanGould avatar Aug 31 '25 17:08 DanGould