Skip to content

Return current state in transient errors#1724

Merged
spacebear21 merged 4 commits into
payjoin:masterfrom
spacebear21:transient-state-return
Jul 10, 2026
Merged

Return current state in transient errors#1724
spacebear21 merged 4 commits into
payjoin:masterfrom
spacebear21:transient-state-return

Conversation

@spacebear21

@spacebear21 spacebear21 commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator

Closes #1422. Closes #1709.

Problem

Transient state transitions consume the typestate and return only an error. An implementer who hits a transient failure (a directory 5xx, a wallet RPC hiccup) has no way to resume: they must either defensively clone the state before every transition call or replay the whole session event log. On top of that, PersistedError collapsed the transient/fatal distinction that the internal ApiError variants already encode, so poll loops could not even tell whether retrying was appropriate.

Approach

Transient rejections now return ownership of the current typestate inside the error, the way mpsc::SendError hands back the unsent value. This preserves typestate linearity: at every point exactly one live handle exists, either Ok(next_state), the current state inside the transient Err (via the new PersistedError::transient_state()), or none on a fatal error that closed the session. Transient rejections persist nothing, so the carried state is always identical to what replaying the event log would reconstruct, and a retried run leaves an event log indistinguishable from one that never failed. Both properties are covered by new tests.

  • RejectTransient, Rejection, ApiError, and PersistedError gain a CurrentState type parameter defaulting to (). MaybeFatalTransition, MaybeTransientTransition, and MaybeTerminalSuccessTransition gain a trailing CurrentState parameter; the WithNoResults and FatalOrSuccess transitions reuse the CurrentState they already carry for the stasis arm. Every transient constructor now carries the state, uniformly.
  • process_error_response now consumes self like every other transition. It was the one borrowing transition, which left a live handle able to re-post and append a duplicate event after a successful save; consuming self extends the exactly-one-live-handle invariant to the error-reply path.
  • PersistedError gains is_transient() / is_fatal() predicates (Be specific about error handling for methods that don't return state transitions #1422). Storage errors are neither: the transition outcome is unknown and recovery is replay.
  • Over FFI, uniffi error enums cannot carry methods, so the classification is encoded in the variant structure: ReceiverPersistedError and SenderPersistedError become Transient/Fatal/Storage, surfacing as distinct exception types in each binding language. The sender's per-kind variants move into a new SenderError enum mirroring ReceiverError. This breaks binding consumers matching the old variant names, which seems acceptable pre-1.0.
  • The FFI builder error is renamed from BuildSenderError to SenderBuilderError, mirroring ReceiverBuilderError. Besides the symmetry, this avoids a class-name collision in the generated Dart, where uniffi-dart flattens the SenderError::Build variant and the BuildSenderError object to the same top-level BuildSenderException class.
  • payjoin-cli demonstrates the API end to end: both poll loops and all three one-shot POST flows (Original PSBT, proposal, error reply) retry transient errors from the state carried in the error, paced by a fixed 5 second delay and bounded by session expiry. The receiver's proposal-checking chain intentionally keeps propagating transient errors, since their recovery path is resuming the session, and apply_fee_range's transient error is deterministic for a given proposal, so an in-place retry loop would spin until session expiry.

Trade-off worth reviewer attention

Three receiver transitions (identify_receiver_outputs, apply_fee_range, finalize_proposal) moved inner data into by-value calls before the transient return, so keeping self intact costs one PSBT-sized clone per call on the happy path. The alternative was changing inner by-value signatures shared with the v1 receiver, which seemed worse.

Since this change is breaking (new type parameters), it must land before 1.0; the #[non_exhaustive] escape hatch would not have covered it later.

Disclosure: co-authored by Claude Code

🤖 Generated with Claude Code

@coveralls

coveralls commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator

Coverage Report for CI Build 29110198013

Coverage increased (+0.02%) to 86.026%

Details

  • Coverage increased (+0.02%) from the base build.
  • Patch coverage: 73 uncovered changes across 4 files (332 of 405 lines covered, 81.98%).
  • 7 coverage regressions across 2 files.

Uncovered Changes

File Changed Covered %
payjoin-cli/src/app/v2/mod.rs 72 27 37.5%
payjoin/src/core/receive/v2/mod.rs 106 84 79.25%
payjoin/src/core/persist.rs 223 219 98.21%
payjoin/src/core/send/v2/mod.rs 4 2 50.0%

Coverage Regressions

7 previously-covered lines in 2 files lost coverage.

File Lines Losing Coverage Coverage
payjoin/src/core/receive/v2/mod.rs 4 92.01%
payjoin-cli/src/app/v2/mod.rs 3 50.66%

Coverage Stats

Coverage Status
Relevant Lines: 15887
Covered Lines: 13667
Line Coverage: 86.03%
Coverage Strength: 343.55 hits per line

💛 - Coveralls

@benalleng benalleng left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I am ok with any of the shapes this takes and this is looking good. I'm curious what we want to change in the cli with this approach. There is a dart exception naming problem

Comment thread payjoin-ffi/src/send/error.rs Outdated
@spacebear21 spacebear21 force-pushed the transient-state-return branch from c3c3df9 to 3e3593d Compare July 9, 2026 23:35

@benalleng benalleng left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

CACK I would still like to see the how the usage of e.is_transient() and e.transient_state() takes its shape in the cli

@DanGould

DanGould commented Jul 10, 2026

Copy link
Copy Markdown
Member

CLI impl with e.is_transient() & e.transient_state https://github.com/DanGould/rust-payjoin/commits/dangould/1724-cli-transient-impl/

  • sender.clone() removed for mut sender in the function signatures
  • continue removed because now all of the error arms are covered

@spacebear21

spacebear21 commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator Author

I cherry-picked Dan's commit onto this PR and made two minor modifications: added a 5 second retry delay and updated send_payjoin_proposal to make the proposal parameter mut instead of rebinding in the function body, matching the other two function styles.

@spacebear21 spacebear21 marked this pull request as ready for review July 10, 2026 15:18
@spacebear21 spacebear21 requested a review from benalleng July 10, 2026 15:18
@xstoicunicornx

Copy link
Copy Markdown
Collaborator

Haven't completed thorough review but offhand it looks like Dan's commit is still missing the transient error handling in handle_error (the code snippet that I pasted in #1709)

if let Err(e) = session.process_error_response(&err_bytes, err_ctx).save(persister) {
if let Some(api_err) = e.api_error_ref() {
tracing::warn!("Failed to confirm error response delivery: {api_err}");
}
match e.error_state() {
Some(_) => {
let id = persister.session_id();
println!(
"Session {id} failed. Run `payjoin-cli cancel {id}` to cancel and broadcast the fallback transaction."
);
}
None => return Err(anyhow!("Failed to process error response")),
}
}

@spacebear21 spacebear21 force-pushed the transient-state-return branch from 99f0e55 to bff7d29 Compare July 10, 2026 15:42
@spacebear21

Copy link
Copy Markdown
Collaborator Author

Updated to add retry for handle_error and also post_original_proposal.

Note that there are remaining transient errors in the receiver checklist chain that are intentionally left out here as they come from wallet/db callbacks, and in-place retry loops wouldn't achieve anything there. We could add case-by-case handling there in follow-ups (e.g prompting for a new set of inputs, different fee preferences, etc.)

@xstoicunicornx xstoicunicornx left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I think were still missing current event handling for one of the transient errors.

Comment thread payjoin/src/core/persist.rs Outdated
spacebear21 and others added 4 commits July 10, 2026 13:07
Transient state transitions consumed the typestate and returned only
an error, so callers could not retry without cloning the state ahead
of every call or replaying the whole session event log.

Carry ownership of the current typestate inside the transient
rejection, the way mpsc::SendError hands back the unsent value. This
preserves typestate linearity: a transition yields the next state on
success, hands the same state back on a transient error, or closes
the session on a fatal error. Transient rejections persist nothing,
so the returned state always matches what replay would reconstruct.

RejectTransient, Rejection, ApiError, and PersistedError gain a
CurrentState parameter defaulting to (). MaybeFatalTransition,
MaybeTransientTransition, and MaybeTerminalSuccessTransition grow a
trailing CurrentState parameter; the WithNoResults and
FatalOrSuccess transitions reuse the CurrentState they already
carry for the stasis arm. The new PersistedError::transient_state
accessor recovers the state.

process_error_response now consumes self like every other
transition. It was the one borrowing transition, which left a live
handle able to re-post and append a duplicate event after a
successful save; consuming self extends the exactly-one-live-handle
invariant to the error-reply path, with the receiver returned
through the transient error like everywhere else.

Sites that moved inner data into by-value calls before the transient
return (identify_receiver_outputs, apply_fee_range,
finalize_proposal) now clone that data up front, adding one
PSBT-sized clone to their happy paths.
Poll loops and retry logic need to branch on whether a persisted
error is retryable, but PersistedError collapsed the distinction
that the internal ApiError variants already encode (issue payjoin#1422).

Add is_transient() and is_fatal() predicates. A transient error
carries the state to resume from via transient_state(); a fatal
error closed the session or moved it to an error state. Storage
errors are neither: the transition outcome is unknown and recovery
is replaying the event log.

Cover the retry loop end to end: retrying a failed transition from
the state returned by transient_state() produces an event log
identical to a run that never failed, and the carried state equals
what replaying the event log reconstructs.
Bindings had no way to tell whether a failed transition is
retryable: ReceiverPersistedError and SenderPersistedError
flattened the distinction the core error model encodes
(issue payjoin#1422).

uniffi error enums cannot carry methods, so encode the
classification in the variant structure, which surfaces as
distinct exception types in each binding language. Both enums
become Transient/Fatal/Storage, with the protocol error carried
inside the first two. The sender's per-kind variants move into a
new SenderError enum nested inside the classification, mirroring
the receiver's ReceiverError, and its three From impls collapse
into a macro mirroring the receiver's. The From impls pick the
variant with PersistedError::is_transient.

Rename the FFI builder error from BuildSenderError to
SenderBuilderError, mirroring ReceiverBuilderError. Besides the
symmetry, this avoids a name collision in the generated Dart:
uniffi-dart flattens each error-enum variant to a top-level class
named <Variant><ParentException>, so SenderError::Build produces
BuildSenderException, the same class the BuildSenderError object
generates once its Error suffix is rewritten to Exception.
Add transient arms to the two existing poll loops and convert the
three one-shot POST flows into retry loops that recover the state
with transient_state().

- get_proposed_payjoin_psbt: transient arm retries the poll in
  place. Drops the defensive sender.clone() that existed because
  the state was previously unrecoverable from errors.
- long_poll_fallback: transient arm retries the poll in place.
- send_payjoin_proposal: becomes a retry loop; re-POSTing the
  finalized proposal is idempotent from both Bitcoin and directory
  perspectives.
- post_original_proposal: becomes a retry loop; re-POSTing the
  Original PSBT is idempotent from the directory's perspective.
- handle_error: branches on is_transient() and retries the error
  reply, resolving the ambiguity between Transient and
  FatalTerminate outcomes (issue payjoin#1709).

Transient retries are paced by a fixed 5 second delay so a
misbehaving directory or relay is not hammered in a tight loop.
No delay is needed on the happy path because the directory holds
long polls open, and no retry cap is needed because the request
constructors fail once the session expires, exiting each loop
through the existing fatal arms.

The receiver's proposal-checking chain intentionally keeps
propagating transient errors instead of retrying in place: those
come from wallet and database callbacks whose recovery path is
resuming the session, and apply_fee_range's transient error is
deterministic for a given proposal, so an in-place retry loop
would spin until session expiry.

Co-authored-by: spacebear <git@spacebear.dev>
@spacebear21 spacebear21 force-pushed the transient-state-return branch from bff7d29 to a7c000e Compare July 10, 2026 17:13

@benalleng benalleng left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

ACK a7c000e transient errors now carry state where relevant in the cli.

Given the expiration nature of payjoins I think a 5 second retry is appropriate rather than a give-up after n retires path.

@spacebear21 spacebear21 dismissed xstoicunicornx’s stale review July 10, 2026 18:50

addressed in latest push

@spacebear21 spacebear21 merged commit 436749f into payjoin:master Jul 10, 2026
22 checks passed

@xstoicunicornx xstoicunicornx left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

ACK a7c000e

Overall looks good. State machine feels much more consistent overall.

A few suggestions, 2 of which touch API for payjoin crate. PRs for the API updates incoming.

}
}

pub fn error_state(self) -> Option<ErrorState> {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

May want rename to fatal_state for consistency/clarity?

/// search for the transaction in the network. Since a non-SegWit input signature is going to
/// change the TXID of the Payjoin proposal, it cannot be monitored.
pub fn check_for_transaction(
&self,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

May want to also consume self here for consistency?

let sender = sender.process_response(&response.bytes().await?, ctx).save(persister)?;
println!("Posted Original PSBT...");
self.get_proposed_payjoin_psbt(sender, persister).await
loop {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Rather than introducing a loop couldn't we just call process_receiver_session with the returned transient current state?

#[uniffi::export(Debug, Display, Eq)]
#[error("Error initializing the sender: {msg}")]
pub struct BuildSenderError {
pub struct SenderBuilderError {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Renaming this to align with the ReceiverBuilderError from ffi crate makes it not follow the naming convention from PDK base crate. Why not rename ReceiverBuilderError from ffi crate to be BuildReceiverError instead to bring them both into alignment?

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.

Be specific about error handling for methods that don't return state transitions Ambiguous state when persisting MaybeTerminalSuccessTransition

5 participants