Skip to content

Conversation

@arminsabouri
Copy link
Collaborator

@arminsabouri arminsabouri commented Jun 6, 2025

Please take a look at individual commits for a more complete description of the changes.
commits messages prefixed'd with "squash" indicates that they are meant to be squash'd with the previous commit before this PR gets merged.

One open item before this is undrafted:

  • c7c31fc introduces a In memory receiver sesssion persister. It is accessible to the integration tests but not to sub mod unit tests (?) . Need to move this persister impl a common location or figure out why its not visibile to the unit tests.

@coveralls
Copy link
Collaborator

coveralls commented Jun 6, 2025

Pull Request Test Coverage Report for Build 15764018768

Details

  • 710 of 823 (86.27%) changed or added relevant lines in 8 files are covered.
  • 5 unchanged lines in 3 files lost coverage.
  • Overall coverage decreased (-0.05%) to 86.087%

Changes Missing Coverage Covered Lines Changed/Added Lines %
payjoin-cli/src/db/v2.rs 57 58 98.28%
payjoin/src/receive/v1/exclusive/error.rs 0 1 0.0%
payjoin-cli/src/db/error.rs 0 2 0.0%
payjoin/src/persist.rs 0 12 0.0%
payjoin/src/receive/v2/session.rs 261 279 93.55%
payjoin-cli/src/app/v2/mod.rs 156 191 81.68%
payjoin/src/receive/v2/mod.rs 234 278 84.17%
Files with Coverage Reduction New Missed Lines %
payjoin/src/directory.rs 1 92.0%
payjoin-cli/src/app/v2/mod.rs 2 81.02%
payjoin/src/receive/v2/mod.rs 2 87.75%
Totals Coverage Status
Change from base Build 15736242427: -0.05%
Covered Lines: 7617
Relevant Lines: 8848

💛 - Coveralls

@DanGould
Copy link
Contributor

DanGould commented Jun 7, 2025

payjoin-test-utils is really for those dependencies that we don't want to include in payjoin. This PR creates circular dependency by defining InMemoryTestPersister in payjoin-test-utils, which itself depends on payjoin, and using that as a test dependency for payjoin.

Rationale for InMemoryPersister is not yet in the commit log. So I had to do some serious digging to figure out why it was there. I suppose you want to play back the session event log for those unit tests and integration tests where NoopPersister will not actually hand you the history. It would seem to me then that this persister is defined in the payjoin test module where it is used, it may be exported behind a _test-utils feature flag for re-export in payjoin-test-utils if NoopSessionPersister is insufficient in integration tests.

edit: I did an implementation on this branch. There are some other optimizations to the [patch.crates-io] Cargo.toml sections that were also potential sources of discrepancy.

@arminsabouri
Copy link
Collaborator Author

payjoin-test-utils is really for those dependencies that we don't want to include in payjoin. This PR creates circular dependency by defining InMemoryTestPersister in payjoin-test-utils, which itself depends on payjoin, and using that as a test dependency for payjoin.

Rationale for InMemoryPersister is not yet in the commit log. So I had to do some serious digging to figure out why it was there. I suppose you want to play back the session event log for those unit tests and integration tests where NoopPersister will not actually hand you the history. It would seem to me then that this persister is defined in the payjoin test module where it is used, it may be exported behind a _test-utils feature flag for re-export in payjoin-test-utils if NoopSessionPersister is insufficient in integration tests.

Rationale should be in the commit that introduce it but also mentioned here fc4bbe7.

edit: I did an implementation on this branch. There are some other optimizations to the [patch.crates-io] Cargo.toml sections that were also potential sources of discrepancy.

Thanks for taking this on. I will apply this in the history.

@arminsabouri arminsabouri force-pushed the recv-state-transition-objects branch from f860f88 to 9d4b630 Compare June 9, 2025 16:00
@arminsabouri
Copy link
Collaborator Author

  • Rebased and resolved conflicts against master.
  • Implemented changes from this branch.

Going to wait for CI to pass and do comb thru before opening for review

Comment on lines +106 to +107
impl From<&ReplyableError> for JsonReply {
fn from(e: &ReplyableError) -> Self {
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

note to self: Can we revert this? Why was this neccecary?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This is because we persist the JSON replayable error in the terminal session event. And we take ownership in two places

let inner = self.state.v1.commit_outputs();
Receiver { state: WantsInputs { v1: inner, context: self.state.context } }

Solution here:

  • Impl clone on the Replayable error
  • Impl From on a refrence of replayable error.
  • Impl From on a refrence to an error is not rusty idomatic we can also have a function on JsonReply that takes a refrence to the error

Copy link
Collaborator

Choose a reason for hiding this comment

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

Do you intend on resolving this in this PR or as a follow-up?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I think this would be better left as a follow up. There is a refactor we would need to do to get error types persisted into terminal session events and remove the JSON replies all together and this can get resolved as part of that follow up. Leaving this comment open so can make an follow up issue

@arminsabouri arminsabouri force-pushed the recv-state-transition-objects branch from 9d4b630 to b69e634 Compare June 9, 2025 19:01
@arminsabouri arminsabouri force-pushed the recv-state-transition-objects branch from b69e634 to 8f9dd22 Compare June 9, 2025 20:38
@arminsabouri arminsabouri marked this pull request as ready for review June 9, 2025 20:39
@arminsabouri arminsabouri marked this pull request as ready for review June 16, 2025 14:21
Copy link
Collaborator Author

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

Noticed a couple things. Additionally, all the state transition methods can now be pub(crate)'d. That should be done in its own commit

@arminsabouri arminsabouri marked this pull request as draft June 16, 2025 15:54
@arminsabouri arminsabouri force-pushed the recv-state-transition-objects branch from ca11392 to b9889aa Compare June 16, 2025 15:55
@arminsabouri
Copy link
Collaborator Author

Last push reverts the change that moved state transition save()'s internally. Keeping the type states concerned purely with computing the next state allows us to have first class support for async runtimes in the future.

@arminsabouri arminsabouri marked this pull request as ready for review June 16, 2025 16:16
Copy link
Collaborator

@spacebear21 spacebear21 left a comment

Choose a reason for hiding this comment

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

IMO a95bdd8 does too many things and should be split up into smaller chunks to reduce the cognitive burden on reviewers and help future contributors understand what happened here. I would try to split these into standalone commits:

  • Rename persist.rs to session.rs
  • Rename NewReceiver to UninitializedReceiver
  • Introduce ReceiverTypeState and the apply functions
  • Introduce SessionHistory, replay_event_log and the ReplayError type
  • Remove receiver_ser_de_roundtrip - It's unclear to me what change this relates to, perhaps it was a leftover from when test_session_event_serialization_roundtrip was introduced in receive/persist.rs?
  • payjoin-cli changes
  • Remove the obsolete persist and load methods and, and probably the Persister trait and NoopPersister implementation too (I don't think they're needed anymore?).

The commit message says:

Changes to v1/mod.rs allow the session history manager to expose the PSBT at this specific state. This update addresses a requirement from the Liana integration, where the contributed PSBT must be saved to a separate database.

I don't see a change in v1/mod.rs regarding this. If there is such a change, it sounds like it should be a standalone commit.

Comment on lines +106 to +107
impl From<&ReplyableError> for JsonReply {
fn from(e: &ReplyableError) -> Self {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Do you intend on resolving this in this PR or as a follow-up?

@arminsabouri
Copy link
Collaborator Author

arminsabouri commented Jun 17, 2025

IMO a95bdd8 does too many things and should be split up into smaller chunks to reduce the cognitive burden on reviewers and help future contributors understand what happened here. I would try to split these into standalone commits:

  • Rename persist.rs to session.rs
  • Rename NewReceiver to UninitializedReceiver
  • Introduce ReceiverTypeState and the apply functions

Replay error type would need to get introduce here.

  • Introduce SessionHistory, replay_event_log and the ReplayError type
  • Remove receiver_ser_de_roundtrip - It's unclear to me what change this relates to, perhaps it was a leftover from when test_session_event_serialization_roundtrip was introduced in receive/persist.rs?

Not leftover bc we we're still doing persistence via the persister trait as of #760. a95bdd8 deprecates it and replaces with persisting session events.

  • payjoin-cli changes
    Note that these changes would have to be in lock step with deprecating the old persister trait.
  • Remove the obsolete persist and load methods and, and probably the Persister trait and NoopPersister implementation too (I don't think they're needed anymore?).

Sender is still using these.

The commit message says:

Changes to v1/mod.rs allow the session history manager to expose the PSBT at this specific state. This update addresses a requirement from the Liana integration, where the contributed PSBT must be saved to a separate database.

I don't see a change in v1/mod.rs regarding this. If there is such a change, it sounds like it should be a standalone commit.

@arminsabouri arminsabouri force-pushed the recv-state-transition-objects branch from b9889aa to 0260a3b Compare June 17, 2025 12:44
@arminsabouri
Copy link
Collaborator Author

arminsabouri commented Jun 17, 2025

One more commit added here to exported api side errors in ffi state transition methods
3096f39

@arminsabouri arminsabouri force-pushed the recv-state-transition-objects branch 2 times, most recently from 7018f74 to 8c6052e Compare June 18, 2025 15:29
@arminsabouri arminsabouri requested a review from spacebear21 June 18, 2025 18:33
Copy link
Contributor

@DanGould DanGould left a comment

Choose a reason for hiding this comment

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

This keeps getting bigger!???!? It seems like it is in workable state but our best option may be to consider merging what we have at the end of the day and then listing the follow ups so that the work can be divided.

Sure, I'd (much) rather the commits were smaller to begin with but the facts on the ground are that I don't think either you @0xBEEFCAF3 or @spacebear21 are completely opposed to what we have here and our best course of action is probably to do systematic re-review once this PR is merged of a list of the priorities that need to get done so that we can ship this into the world.

I particularly like 623c40e because that match was stressing me out. I definitely have concerns with the way things are named in this PR, but again, we can solve that after.

And in considering the name changes I think I figured out why the TerminalState special case is wonky and shouldn't actually be a special case, which I touch on below.

I don't want to leave my ACK, because it's 02:19 and I really want Spacebear to look again before merge, but I think we're quite close to good enough for merge, have learned a significant amount we can apply to next time, and this gets us over the hump to actually roll the release.

Your perseverance is palpable ⭐️

Comment on lines 90 to 167
pub fn pj_uri<'a>(&self) -> Option<PjUri<'a>> {
self.events.iter().find_map(|event| match event {
SessionEvent::Created(session_context) => {
// TODO this code was copied from ReceiverWithContext::pj_uri. Should be deduped
use crate::uri::{PayjoinExtras, UrlExt};
let id = session_context.id();
let mut pj = subdir(&session_context.directory, &id).clone();
pj.set_receiver_pubkey(session_context.s.public_key().clone());
pj.set_ohttp(session_context.ohttp_keys.clone());
pj.set_exp(session_context.expiry);
let extras = PayjoinExtras {
endpoint: pj,
output_substitution: OutputSubstitution::Disabled,
};
Some(bitcoin_uri::Uri::with_extras(session_context.address.clone(), extras))
}
_ => None,
})
}

/// Payment amount from the Payjoin URI
pub fn pj_uri_amount(&self) -> Option<bitcoin::Amount> { self.pj_uri().map(|uri| uri.amount)? }

fn key(&self) -> Self::Key { ReceiverToken(self.context.id()) }
/// Payment address from the Payjoin URI
pub fn payment_address(&self) -> Option<bitcoin::Address<bitcoin::address::NetworkChecked>> {
self.pj_uri().map(|uri| uri.address)
}

/// Fallback txid from the original proposal
pub fn fallback_txid(&self) -> Option<bitcoin::Txid> {
self.events.iter().find_map(|event| match event {
SessionEvent::UncheckedProposal((proposal, _)) =>
Some(proposal.psbt.unsigned_tx.compute_txid()),
_ => None,
})
}

/// Fallback tx from the original proposal
pub fn fallback_tx(
self,
) -> Option<Result<bitcoin::Transaction, bitcoin::psbt::ExtractTxError>> {
self.events.into_iter().find_map(|event| match event {
SessionEvent::UncheckedProposal((proposal, _)) => Some(proposal.psbt.extract_tx()),
_ => None,
})
}

/// Original psbt from the original proposal
pub fn original_psbt(&self) -> Option<bitcoin::Psbt> {
self.events.iter().find_map(|event| match event {
SessionEvent::UncheckedProposal((proposal, _)) => Some(proposal.psbt.clone()),
_ => None,
})
}

/// Proposed payjoin psbt from the payjoin proposal
pub fn proposed_payjoin_psbt(&self) -> Option<bitcoin::Psbt> {
self.events.iter().find_map(|event| match event {
SessionEvent::PayjoinProposal(proposal) => Some(proposal.psbt().clone()),
_ => None,
})
}

/// Psbt with receiver contributed inputs
pub fn psbt_with_contributed_inputs(&self) -> Option<bitcoin::Psbt> {
self.events.iter().find_map(|event| match event {
SessionEvent::ProvisionalProposal(proposal) => Some(proposal.payjoin_psbt.clone()),
_ => None,
})
}

/// Terminal error from the session if present
pub fn terminal_error(&self) -> Option<(String, Option<JsonReply>)> {
self.events.iter().find_map(|event| match event {
SessionEvent::SessionInvalid(err_str, reply) => Some((err_str.clone(), reply.clone())),
_ => None,
})
}
Copy link
Contributor

Choose a reason for hiding this comment

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

I'd like to prune the nonesential of these functions (anything that can be pulled from pj_uri) in an immediate follow-up. In the future, prune anything that's not used in the PR or we know to have immediate use downstream please it simplifies review.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Fair comment. The ones that are in use are

  • Pj_uri -> used to get the ohttp relay when we need to send the err req.
  return Err(handle_recoverable_error(
                        reply_error,
                        session,
                        &self
                            .unwrap_relay_or_else_fetch(Some(
                                session_history
                                    .pj_uri()
                                    .expect("Session should exist")
                                    .extras
                                    .endpoint()
                                    .clone(),
                            ))
                            .await?,
                    )
  • psbt_with_contributed_inputs : is used by Liana specifically. Liana will only sign PSBTs in a dedicated Table. This method is used to extract the psbt with reciever inputs so we can save it in this table.
  • terminal_error: is used when we need to call extract_err_req

Note about fallback_tx: Right now the refrence impl is still using the method off UncheckedProposal. And Liana IIRC is saving the fallback psbt when it first receives it. I'd like to unify both implementations to use the session history fallback tx method. We can remove it now and re-introduce it when we use it in the refrence impl. This can be done in an immediate follow up.

Copy link
Contributor

@DanGould DanGould Jun 19, 2025

Choose a reason for hiding this comment

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

my issue is more with the params off of pj_uri, the amount of the request and the address since both can be altered by output substitution their names are ~misleading and their fields are already available from the the PjUri type

psbt_with_contributed_inputs still seems like a hack since the caller has the inputs to manage itself, and Liana should probably handle liana-specific details, but no reason for a veto. maybe the name should actually be unsigned_proposal_psbt. We can revisit this when reviewing the liana integration

re: fallback tx docs: fallback tx is the transaction extracted from the Original PSBT in BIPS 77/78 parlance. There is no "Original proposal", there are Original PSBT and Proposal PSBT afaiu.

/// Each variant wraps a `Receiver` with a specific state type, except for `TerminalState` which
/// indicates the session has ended or is invalid.
#[derive(Debug, Clone, PartialEq)]
pub enum ReceiverTypeState {
Copy link
Contributor

Choose a reason for hiding this comment

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

The name of this has been bugging me. AnyReceiver or ReceiverEnum might be more appropriate so that there's no confusion with Receiver<State>'s receiver::State.

I'm pretty sure making TerminalState a receiver::State could also fix the SessionInvalid error type shenanigans in SessionEvent. TerminalState could the error and can produce / remember the JsonError, which would follow the same pattern nearly every other SessionEventObject containing the state type. Perhaps even every state in SessionEvent could reflect an AnyReceiver variant's internal state.

}

fn process_v2_proposal(
#[allow(clippy::incompatible_msrv)]
Copy link
Contributor

Choose a reason for hiding this comment

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

Gotta open an issue to fix these incompatible_msrv exceptions. They're not MSRV compliant!

Comment on lines +362 to +359
async fn commit_outputs(
&self,
proposal: Receiver<WantsOutputs>,
persister: &ReceiverPersister,
) -> Result<()> {
let proposal = proposal.commit_outputs().save(persister)?;
self.contribute_inputs(proposal, persister).await
}
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't find way these are chained to be intuitive. Why does commit_outputs call contribute_inputs? contribute_inputs doesn't seem like a part of commit_outputs at all.

and so on for each of these transitions. Because of this, I don't find thier separation into functions to be improving readability and would prefer them to be called imperatively in a list.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Perhaps we just need a naming change. e.g process_commit_outputs_state.
Alternatively what can be done is to return the sumtype and iterate in process_receiver_proposal until the session fails or succeeds. I find this to be less intuitive.

Copy link
Contributor

Choose a reason for hiding this comment

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

I don't think a rename to process_commit_output_state resolves my concern here.

would a single function that looped over a match on the sumtype, where each branch called a process function work? The weird part is the nested chain even more than the fact that the function calls another function unrelated to the parent's name.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

would a single function that looped over a match on the sumtype, where each branch called a process function work?

Yes this would work

This commit updates the `v2::Receiver` state transition functions to
return state transition objects. Each state transition produces
different outcomes based on the current state. When persisted, a state
transition object returns the next valid state.

Changes to `v2::receiver`

Serialization and Deserialization Error Code

Terminal session events now store JSON replies. These events must derive
`serde::Serialize` and `serde::Deserialize` to support serialization and
deserialization.

`v1/mod.rs`

The session history object enables application developers to introspect
a session and retrieve the Payjoin PSBT after inputs have been
contributed. Changes to `v1/mod.rs` allow the session history manager to
expose the PSBT at this specific state. This update addresses a
requirement from the Liana integration, where the contributed PSBT must
be saved to a separate database.

`session.rs`

This file now contains the core logic for the session history manager,
receiver session events, and associated error types.

`receive/v2/mod.rs`

Introduced a sum type over all possible receiver states. This sum type
processes a session event and, if the event is valid for the current
state, progresses to the next state by calling the typestate's `apply`
function. The `apply` function provides a shorthand mechanism to
transition to the next state based on the session event.

Replaced `NewReceiver` with `UninitializedReceiver` and introduced a
`create_session` method, which takes the same parameters as
`NewReceiver::new`.

Updated each state transition method to match on specific errors. The
system classifies errors as either transient (`ImplementationError`) or
fatal (`SessionError`).

Changes to Payjoin-CLI

Database

Introduced a session wrapper struct to persist session events. This
wrapper will support both sender and receiver sessions. As session
updates occur, the system deserializes the session wrapper, appends the
new event log, and reserializes the session. Each session has a unique
`u64` identifier, derived via `serde`. When a session completes (either
successfully or due to a terminal error), the session persister records
a `completed_at` timestamp.

Application

Refactored the receiver session handling to eliminate compile-time state
assumptions. Introduced a unified handler that matches on any receiver
state and drives the session to completion. Each state handler now
exists as an individual method, and each method invokes the next state
handler until the session completes successfully or encounters a
terminal error.

Lastly, replaced `try_contributing_inputs` with `contribute_inputs` for clarity
and consistency.
This commit monomorphizes each state transition object to be specific to
its corresponding state transition, removing generic parameters to
enable UniFFI exposure. Each state transition object is wrapped in an
`Arc<RwLock<Option<Object>>>` to allow FFI bindings to reference self
safely. UniFFI does not enforce Rust’s strong borrowing guarantees and
operates more predictabily with `&self`.

Additionally, this commit introduces `JsonReceiverSessionPersister`,
enabling applications to persist and load session events using exposed
to_json and from_json methods on `ReceiverSessionEvent`.
FFI state transitions objects were returning `ImplementationError` which made no
use of our FFI exported receiver error types. The new `PersistedError`
type will wrap around api side error if they occur and use
`ImplementationError` in the case of an application storage related
error
This commit refactors the extraction of error requests and the handling
of responses. Previously, these methods were only available on the
`UncheckedProposal` typestate. However, a session may fail at any
typestate, and the receiver must send an error response to the sender if
the failure is fatal.

The extract_err_req and process_err_res functions are now implemented as
pure functions. By replaying the session event log, applications can
call `extract_err_req` at any point. If the session is in a terminal
state, the function exposes the associated Request and OHTTP context.
This commit adds test coverage for `replay_receiver_event_log` and the
session history object produced by replaying the logs. The tests persist
session event logs and replay them to verify that the resulting state
matches the expected state.
Renamed `persist.rs` to `session.rs` as the file no longer manages
persistence logic. It now handles session replays and history
management.
Copy link
Collaborator

@spacebear21 spacebear21 left a comment

Choose a reason for hiding this comment

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

utACK. I think FFI in particular may need a closer look after this is merged, probably in parallel with the uniffi-dart integration, but I don't feel strongly enough about any particular thing to keep this PR stuck any longer.

I agree the best course of action would be to merge this and iterate on it as we prepare the release. I'm thinking the best way to do this is to start a new tracking issue for persistence/0.24 release?

Comment on lines +208 to +190
#[uniffi::constructor]
// TODO: no need for this constructor. `create_session` is the only way to create a receiver.
pub fn new() -> Self { Self {} }

Copy link
Collaborator

Choose a reason for hiding this comment

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

Should this be removed?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

yes

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

The issue is create_session does not return Self and uniffi does not like this.

Comment on lines +17 to +19
/// Error that may occur when converting a some type to a URL
#[error("IntoUrl error: {0}")]
IntoUrl(Arc<IntoUrlError>),
Copy link
Collaborator

Choose a reason for hiding this comment

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

Is this new variant necessary? Ideally the ffi API should map as closely as possible to the Rust API.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I think we can get around having to add this variant

@arminsabouri arminsabouri force-pushed the recv-state-transition-objects branch from 8c6052e to 07ae278 Compare June 19, 2025 18:14
@spacebear21 spacebear21 merged commit a784635 into payjoin:master Jun 19, 2025
13 checks passed
@arminsabouri arminsabouri deleted the recv-state-transition-objects branch September 8, 2025 18:51
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.

4 participants