Skip to content

Conversation

@DanGould
Copy link
Contributor

@DanGould DanGould commented Aug 27, 2025

There is a lot of movement here but nothing complex is added or removed. It's a pure shuffle.

The focus of this work is to untangle the v1/v2 logic so that there is a clean split between shared PSBT logic and isolated, version-specific serialization.

In essence:

  • receive::v1::exclusive becomes receive::v1 which is only compiled when feature = "v1".
  • receive::v1 becomes receive::common and includes types used when feature = "_core". Typestates defined here but only used when feature = "v1 are moved to receive::v1. It's always pub(crate) so there's no conditional visibility on whether or not feature = "v1", because that is completely separated into receive::v1.
  • v1 typestates are removed from the v2 state machine and module. v2 no longer depends on the v1 module, only common.
  • Tests are shuffled around so that v1-specificb behavior and common behavior are tested in the appropriate modules. At times, that means test fixture functions and visibility is rearranged. By the end, visibility is almost as private as it gets.

This PR stops short of a visibility, documentation, or naming audit. I may consider renaming receive::common to receive::states but open to suggestion. It does need to be a submodule in order to stay invisibile wrt the receive:: scope. It may go further with newtypes and even a reduction in v2 typestates as follow up, though I think those tasks can follow #995 where ReceiverBuilder is added and the Uninitialized state is removed as well.

Pull Request Checklist

Please confirm the following before requesting review:

  • A human has reviewed every single line of this code before opening the PR (no auto-generated, unreviewed LLM/robot submissions).
  • I have read CONTRIBUTING.md and rebased my branch to produce hygienic commits.

@coveralls
Copy link
Collaborator

coveralls commented Aug 27, 2025

Pull Request Test Coverage Report for Build 17276344135

Details

  • 806 of 827 (97.46%) changed or added relevant lines in 6 files are covered.
  • 2 unchanged lines in 1 file lost coverage.
  • Overall coverage increased (+0.09%) to 85.811%

Changes Missing Coverage Covered Lines Changed/Added Lines %
payjoin/src/core/receive/error.rs 5 6 83.33%
payjoin/src/core/receive/v1/mod.rs 83 86 96.51%
payjoin/src/core/receive/v2/mod.rs 29 34 85.29%
payjoin/src/core/receive/common/mod.rs 582 594 97.98%
Files with Coverage Reduction New Missed Lines %
payjoin/src/core/receive/v2/mod.rs 2 92.82%
Totals Coverage Status
Change from base Build 17275991760: 0.09%
Covered Lines: 8007
Relevant Lines: 9331

💛 - Coveralls

Original { psbt: PARSED_ORIGINAL_PSBT.clone(), params }
}

// TODO: restrict to v1 only since UncheckedProposal is a v1 only type
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is addressed later in the history

impl PsbtContext {
/// Prepare the PSBT by creating a new PSBT and copying only the fields allowed by the [spec](https://github.com/bitcoin/bips/blob/master/bip-0078.mediawiki#senders-payjoin-proposal-checklist)
fn prepare_psbt(self, processed_psbt: Psbt) -> Psbt {
pub(crate) fn prepare_psbt(self, processed_psbt: Psbt) -> Psbt {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function and finalize_proposal are eventually returned to private in this PR. This is temporary


#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub(crate) struct PsbtContext {
pub struct PsbtContext {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The receive::v2::SessionEvent::ProvisionalProposal(PsbtContext) requires this visibility. It's possible to wrap this struct if desired, but PsbtContext is the actual event diff that we're using. Perhaps it could be pruned further, just to the bitcoin::Psbt that's the payjoin_psbt since we already have the original psbt at the UncheckedProposal stage.

IMO UncheckedProposal((Original, Optioncrate::HpkePublicKey)) deserves its own type as well instead of a tuple but that can come after. The UncheckedProposal tuple should probably at least be deconstructed out of the nested tuple to UncheckedProposal(Original, Option<crate::HpkePublicKey>)

/// 1. Remove all sender signatures which were received with the original PSBT as these signatures are now invalid.
/// 2. Sign and finalize the resulting PSBT using the passed `wallet_process_psbt` signing function.
pub fn finalize_proposal(
pub(crate) fn finalize_proposal(
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as stated above this can be made private later on when some more shuffling is done.

self,
v1: common::ProvisionalProposal,
) -> ReceiveSession {
pub(crate) fn apply_provisional_proposal(self, psbt_context: PsbtContext) -> ReceiveSession {
Copy link
Contributor Author

@DanGould DanGould Aug 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can see making a NewType/new name for this, but it's not strictly necessary yet.

TODO: reduce pub(crate) fn visibility to private visibility for all of these apply state Receiver methods.

Comment on lines 207 to 208
let psbt_context = wants_fee_range.clone()._apply_fee_range(None, None).unwrap();
let psbt = psbt_context
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This use of the private _apply_fee_range function is transient until further separation is done. you'll see.

Copy link
Collaborator

@arminsabouri arminsabouri Aug 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I dont quite understand this comment.
_apply_fee_range is pub(crate)'d. Did you mean to remove this in the latest commit? FWIW this method would be apply_fee_range bc it is used by methods in the crate and apply_fee should be _apply_fee but I dont think a _ prefix is necessary eitherway.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only problem is a naming conflict with v1 when both v1 and v2 feature flags are on.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is pub(crate), by private I mean that it's only used internally and is not part of the public api. the _ prefix reinforces this.

On a second look I think I should just get rid of this and rely on the actually private apply_fee directly on both v1 and v2 implementations to avoid this confusion.

Comment on lines +199 to +200
let persister = NoopSessionPersister::<SessionEvent>::default();

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The problem here is that the v1 typestate machine was awkwardly being used to produce the v2 types.

If we made helper transition methods that didn't save and just produced errors, we could test without any persister at all, and have the main transition functions call those helpers. But we don't really need that additional boilerplate and can just test using a NoopSessionPersister.

I tried to leave expects where there were expects and otherwise just unwrap().

Comment on lines 33 to 37
pub(crate) original_psbt: Psbt,
pub(crate) payjoin_psbt: Psbt,
pub(crate) params: Params,
pub(crate) change_vout: usize,
pub(crate) owned_vouts: Vec<usize>,
Copy link
Contributor Author

@DanGould DanGould Aug 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because tests are still tangled across the common/v1 modules, this visibility change and others in this commit are required. It's reversed in a later commit which untangles the tests.

/// Protocol-specific errors for BIP-78 v1 requests (e.g. HTTP request validation, parameter checks)
#[cfg(feature = "v1")]
V1(crate::receive::common::RequestError),
V1(crate::receive::v1::RequestError),
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ahhh now you can sense some relief. Nice abstractions are coming soon :)

Comment on lines +509 to +520
let original = original_from_test_vector();
let wants_inputs = WantsOutputs::new(original, vec![0]).commit_outputs();
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Simplified so as not to use the v1-exclusive UncheckedProposal state

@DanGould
Copy link
Contributor Author

DanGould commented Aug 27, 2025

@arminsabouri There is a lot of movement here but nothing complex is added or removed. It's a pure shuffle.

I left comments commit by commit so please review in that way so the comments can help you.

The focus of this work is to untangle the v1/v2 logic so that there is a clean split between shared PSBT logic and isolated, version-specific serialization.

In essence:

  • receive::v1::exclusive is pulled out to become receive::v1 which is only compiled when feature = "v1".
  • receive::v1 becomes receive::common and includes types used when feature = "_core". Typestates defined here but only used when feature = "v1 are moved to receive::v1
  • v1 typestates are removed from the v2 state machine
  • Tests are shuffled around so that v1-specificb behavior and common behavior are tested in the appropriate modules. At times, that means test fixture functions and visibility is rearranged. By the end, visibility is almost as private as it gets.

This PR stops short of a visibility or naming audit. It may go further with newtypes and even a reduction in v2 typestates, though I think those can follow #995 where you add a ReceiverBuilder and remove the uninitialized state as well. Names and documentation probably need to be audited as a follow up as well.

I'm copying much of this message to OP because I think it better describes the issue than OP

@DanGould
Copy link
Contributor Author

I left a bunch of comments on the source so that you can understand why intermediate changes were made. Please let me know if you need any more detail. I'd like to get this in ASAP since it's such a big move and rebase on this is gonna be tricky.

@DanGould DanGould marked this pull request as ready for review August 27, 2025 14:55
Copy link
Collaborator

@arminsabouri arminsabouri left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approach/utACK
Shuffling seems sane. I had a couple questions about some shared abstractions that could also live in common, some struct field renames, and a question about a apply_fee_range when both v1 and v2 feature flags are on. Otherwise had some nits and stylistic nits that can be ignored and/or followed up on if applying historical is too tricky/not worth the time.

let original = original_from_test_vector();
let unchecked_proposal = unchecked_receiver_from_test_vector();
let maybe_inputs_owned =
unchecked_proposal.clone().assume_interactive_receiver().save(&persister).unwrap();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: I have a prefrence over expects here. It describes exactly what error condition out of many we expect not to happen. e.g since assume_interactive_receiver is a next state only transition the only thing we expect not to happen is a storage error. Which is infallible for a noop persister. Marked as nit. Not mission critical.

use crate::output_substitution::OutputSubstitution;
use crate::psbt::PsbtExt;
use crate::receive::{InternalPayloadError, Original, PsbtContext};

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I expected Orignial to live here as well as its a common abstraction. Any reason why its left out?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same thing for PsbtContext

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What you seem to be asking for is to empty receive/mod.rs in favor of common. But we want common specifically to hide types from the top level receive API where Original and PsbtContext are exported. It's not a catchall. receve::common is only for hidden shared types. Otherwise I would put everything in common into recieve/mod.rs.

Now, Original and PsbtContext almost definitely ought to become private in receive/mod.rs, but right now they're in the public interface of receive::v2::session::SessionEvent, so those variants need to encapsulate different or newtypes in order to get rid of that visibility in a follow-up.

change_vout: self.change_vout,
receiver_inputs: self.receiver_inputs,
/// [`RequestError`] should only be produced here.
fn validate_body(headers: impl Headers, body: &[u8]) -> Result<&[u8], RequestError> {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stylistic Nit: This method is only used in UncheckedProposal but defined in the bottom of the file. I would expect to be defined along side build_v1_pj_uri.

Copy link
Contributor Author

@DanGould DanGould Aug 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't we typically put pub fn (like build_..) at the top of the file and fn at the bottom? I didn't write this, just moved it.

Copy link
Collaborator

@arminsabouri arminsabouri Aug 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair enough. If that is consistent with the rest of the repo thats good with me

#[derive(Debug, Clone, PartialEq)]
pub struct WantsOutputs {
v1: v1::WantsOutputs,
v1: common::WantsOutputs,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this is no longer wrapping v1 typestates.

Suggested change
v1: common::WantsOutputs,
inner: common::WantsOutputs,

#[derive(Debug, Clone, PartialEq)]
pub struct WantsInputs {
v1: v1::WantsInputs,
v1: common::WantsInputs,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same thing here

#[derive(Debug, Clone, PartialEq)]
pub struct WantsFeeRange {
v1: v1::WantsFeeRange,
v1: common::WantsFeeRange,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And here

output_contribution_weight
}

impl crate::receive::common::WantsFeeRange {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this be problematic if both v1 and v2 feature flags are on? If both are on IIUC the apply_fee_range impl below would override the one in common.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As far as I can tell this would only be an API-side oddity. Applications cannot call the v1 apply_fee_range method if they have a v2 typestate.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I checked that this is not an issue with cargo doc --open --all-features. This impl is scoped to v1::WantsFeeRangeand does not apply tov2::WantsFeeRangewhich is public, only tocommon::WantsFeeRange*in the v1 scope*.common::WantsFeeRangeis not part of the public interface since this type is only ever publicly exported as part of thereceive::v1` scope.

Comment on lines 207 to 208
let psbt_context = wants_fee_range.clone()._apply_fee_range(None, None).unwrap();
let psbt = psbt_context
Copy link
Collaborator

@arminsabouri arminsabouri Aug 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I dont quite understand this comment.
_apply_fee_range is pub(crate)'d. Did you mean to remove this in the latest commit? FWIW this method would be apply_fee_range bc it is used by methods in the crate and apply_fee should be _apply_fee but I dont think a _ prefix is necessary eitherway.

Comment on lines 207 to 208
let psbt_context = wants_fee_range.clone()._apply_fee_range(None, None).unwrap();
let psbt = psbt_context
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only problem is a naming conflict with v1 when both v1 and v2 feature flags are on.

Code that was only for v1 and NOT v2 was in the common folder.
The `exclusive` name does not make sense in this context since it's really
the `v1` module now. That change comes next.
Make intenal WantsFeeRange::_apply_fee_range function and external v1-exclusive
after which the v1 typestates can be used since the v2 sumtype can use the
underlying type directly (or eventually encapsulate them itself).
@DanGould
Copy link
Contributor Author

Ok I think I fixed the problems you brought up and made sure each and every commit was linted and formatted. I want to address the WantsFeeRange::apply_fee in a name audit follow up. It's just a matter of name conflict but we do want that method _apply_fee_range to exist (really as pub(super), not pub(crate) so that the callers can get the PsbtContext without needing to make common::WantsFeeRange's fields visible. If that's ok as a follow-up with you and the other changes you requested are addressed. I'd like to move forward.

@DanGould DanGould requested a review from arminsabouri August 27, 2025 19:14
Copy link
Collaborator

@arminsabouri arminsabouri left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

utACK de883a6

@DanGould DanGould merged commit 8b1bb2b into payjoin:master Aug 28, 2025
10 checks passed
@DanGould
Copy link
Contributor Author

survived

@DanGould DanGould deleted the refactor-receiver branch August 28, 2025 13:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants