Skip to content

Receiver fallback typestate#1558

Draft
spacebear21 wants to merge 8 commits into
payjoin:masterfrom
spacebear21:fallback-typestate
Draft

Receiver fallback typestate#1558
spacebear21 wants to merge 8 commits into
payjoin:masterfrom
spacebear21:fallback-typestate

Conversation

@spacebear21
Copy link
Copy Markdown
Collaborator

@spacebear21 spacebear21 commented May 14, 2026

Still a draft because a large portion of it hasn't been reviewed by human eyes, but the intent is to illustrate the idea described here for the receiver side. Supersedes #1542.

Planned with Claude Opus 4.7, implemented by Codex 5.5

Pull Request Checklist

Please confirm the following before requesting review:

@spacebear21 spacebear21 changed the title Fallback typestate Receiver fallback typestate May 14, 2026
@arminsabouri arminsabouri mentioned this pull request May 15, 2026
2 tasks
@spacebear21 spacebear21 force-pushed the fallback-typestate branch from d54f24f to f4454b7 Compare May 15, 2026 20:55
Introduce a sealed::FallbackTx trait with a non-Option fallback_tx()
method, implemented inside the sealed module for the in-protocol
receiver states whose contract includes a confirmed broadcastable
fallback. Expose access through HasFallbackTx, a public marker trait
that has no methods of its own and is implemented for any type
satisfying sealed::FallbackTx via a blanket impl. External crates can
bound on HasFallbackTx but cannot implement it, and the method itself
is only callable from inside the receive::v2 module where the sealed
trait is in scope.

- UncheckedOriginalPayload is deliberately excluded;
it holds the sender's Original PSBT but has not yet run
check_broadcast_suitability, so the PSBT is not yet verified as
broadcastable.
- HasReplyableError is also excluded; it will gain an
optional fallback field in a later commit and continues to model the
absent-fallback case at runtime.

To avoid naming conflicts in intermediate commits, the existing
`fallback_tx() -> Option<Transaction>` implementation is renamed
to `maybe_fallback_tx`. It is removed entirely in a later commit.
Fix to pickup local changes in JS bindings.
PendingFallback represents a receiver session that was cancelled or
hit a fatal protocol error, and has a fallback transaction available
to broadcast.

While the session sits in PendingFallback the implementer holds an
obligation to broadcast, discard, or otherwise handle the fallback
transaction (e.g. save it to wallet DB for later broadcasting). This
state is preserved across restarts and session replays until the
implemeter calls `close()`, indicating that the handoff of the
fallback transaction is complete and no longer a payjoin concern.
HasReplyableError represents a receiver session that hit a replyable
error before reaching PendingFallback. The struct must model the
runtime fact that some sources can hand it a verified broadcastable
fallback and others cannot. Encoding the field as
Option<Transaction> keeps that distinction at the type level without
weakening the HasFallback trait contract.
Introduce MaybeTerminalTransition for the no-error fork (used by
cancel) and MaybeTerminalSuccessTransition for the error-bearing
fork (used by process_error_response). Both expose advance and
terminate constructors that map to Save and SaveAndClose actions
respectively. The success variant returns Option<NextState>; the
error variants preserve the caller's distinction between transient,
fatal-advance, and fatal-terminate.
The receiver side of v2 had a single blanket cancel implementation
that always terminated the session and handed the wallet an
Option<Transaction>. Fatal protocol errors emitted Closed(Failure)
directly. Both shapes lost the wallet's obligation to broadcast the
original transaction across a restart whenever a fallback existed.

Replace the blanket cancel with typestate-aware impls:
- impl<S: HasFallback> Receiver<S>::cancel advances to PendingFallback
- Receiver<Initialized>::cancel and Receiver<UncheckedOriginalPayload>
  ::cancel terminate with Closed(Cancel); neither holds a verified
  fallback
- Receiver<HasReplyableError>::cancel forks on the optional fallback:
  Some advances to PendingFallback, None terminates with Closed(Cancel)
@spacebear21 spacebear21 force-pushed the fallback-typestate branch from f175e01 to 56641ee Compare May 15, 2026 22:24
The receiver side now lets a session pause in PendingFallback after a
cancel or a fatal protocol error. The cli needs a wallet-facing way to
enter that state, finish it (by broadcasting or discarding), and pick
it up on resume.
@coveralls
Copy link
Copy Markdown
Collaborator

Coverage Report for CI Build 25944298217

Coverage decreased (-0.3%) to 85.039%

Details

  • Coverage decreased (-0.3%) from the base build.
  • Patch coverage: 173 uncovered changes across 5 files (683 of 856 lines covered, 79.79%).
  • 8 coverage regressions across 2 files.

Uncovered Changes

File Changed Covered %
payjoin-cli/src/app/v2/mod.rs 101 6 5.94%
payjoin/src/core/receive/v2/mod.rs 516 448 86.82%
payjoin-cli/src/app/v1.rs 4 0 0.0%
payjoin-cli/src/main.rs 4 0 0.0%
payjoin-cli/src/app/config.rs 2 0 0.0%

Coverage Regressions

8 previously-covered lines in 2 files lost coverage.

File Lines Losing Coverage Coverage
payjoin/src/core/receive/v2/mod.rs 6 90.8%
payjoin-cli/src/app/v2/mod.rs 2 46.82%

Coverage Stats

Coverage Status
Relevant Lines: 14377
Covered Lines: 12226
Line Coverage: 85.04%
Coverage Strength: 377.13 hits per line

💛 - Coveralls

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.

2 participants