Skip to content

Receiver fallback typestate#1558

Open
spacebear21 wants to merge 7 commits into
payjoin:masterfrom
spacebear21:fallback-typestate
Open

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

Conversation

@spacebear21
Copy link
Copy Markdown
Collaborator

@spacebear21 spacebear21 commented May 14, 2026

Supersedes #1542.

Receiver counterpart to #1557 . It's a much bigger PR because a) the receiver state machine is more complicated than the sender's, and b) it also implements the "protocol failure" fallback path (i.e. not a manually-initiated cancel, but an irrecoverable error that would also warrant broadcasting the fallback tx).

stateDiagram-v2
      [*] --> Initialized

      Initialized --> UncheckedOriginalPayload: RetrievedOriginalPayload
      UncheckedOriginalPayload --> MaybeInputsOwned: CheckedBroadcastSuitability

      state "9 HasFallbackTx in-protocol states" as InProtocol {
          MaybeInputsOwned --> MaybeInputsSeen: CheckedInputsNotOwned
          MaybeInputsSeen --> OutputsUnknown: CheckedNoInputsSeenBefore
          OutputsUnknown --> WantsOutputs: IdentifiedReceiverOutputs
          WantsOutputs --> WantsInputs: CommittedOutputs
          WantsInputs --> WantsFeeRange: CommittedInputs
          WantsFeeRange --> ProvisionalProposal: AppliedFeeRange
          ProvisionalProposal --> PayjoinProposal: FinalizedProposal
          PayjoinProposal --> Monitor: PostedPayjoinProposal
      }

      UncheckedOriginalPayload --> HasReplyableError: GotReplyableError, fallback_tx is None (broadcast unverified)
      InProtocol --> HasReplyableError: GotReplyableError, fallback_tx is Some

      InProtocol --> PendingFallback: Cancelled (cancel from any HasFallbackTx state)
      PayjoinProposal --> PendingFallback: ProtocolFailed (fatal process_response)
      HasReplyableError --> PendingFallback: Cancelled or ProtocolFailed, when fallback_tx is Some

      Initialized --> Closed: Closed(Cancel) on cancel, or Closed(Failure) on fatal process_response
      UncheckedOriginalPayload --> Closed: Closed(Cancel) on cancel
      HasReplyableError --> Closed: Closed(Cancel) on cancel, or Closed(Failure) on process_error_response, when fallback_tx is None
      Monitor --> Closed: check_payment observes Success, FallbackBroadcasted, or PayjoinProposalSent

      PendingFallback --> Closed: close() emits Closed(Cancel) after Cancelled entry, or Closed(Failure) after ProtocolFailed entry

      Closed --> [*]

      note right of PendingFallback
          Holds the wallet's obligation to broadcast
          or explicitly discard the original tx.
          Stays active until close() so it survives
          resume cycles.
      end note
Loading

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 2 times, most recently from f4454b7 to f175e01 Compare May 15, 2026 21:39
@spacebear21 spacebear21 force-pushed the fallback-typestate branch from f175e01 to 56641ee Compare May 15, 2026 22:24
@coveralls
Copy link
Copy Markdown
Collaborator

coveralls commented May 15, 2026

Coverage Report for CI Build 26202708607

Coverage increased (+0.04%) to 85.179%

Details

  • Coverage increased (+0.04%) from the base build.
  • Patch coverage: 110 uncovered changes across 3 files (691 of 801 lines covered, 86.27%).
  • 21 coverage regressions across 3 files.

Uncovered Changes

File Changed Covered %
payjoin/src/core/receive/v2/mod.rs 515 447 86.8%
payjoin-cli/src/app/v2/mod.rs 54 13 24.07%
payjoin-cli/src/db/v2.rs 1 0 0.0%

Coverage Regressions

21 previously-covered lines in 3 files lost coverage.

File Lines Losing Coverage Coverage
payjoin-cli/src/app/v2/mod.rs 14 51.29%
payjoin/src/core/receive/v2/mod.rs 6 90.76%
payjoin-cli/src/app/v1.rs 1 69.33%

Coverage Stats

Coverage Status
Relevant Lines: 14378
Covered Lines: 12247
Line Coverage: 85.18%
Coverage Strength: 377.71 hits per line

💛 - Coveralls

@DanGould
Copy link
Copy Markdown
Contributor

I read the writeups and concept ACK. I wonder if MayBroadcastFallback is a more appropriate name that implies an action than HasFallback.

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.
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.
@spacebear21
Copy link
Copy Markdown
Collaborator Author

I wonder if MayBroadcastFallback is a more appropriate name that implies an action than HasFallback.

In the latest iteration of this PR the typestate is called PendingFallback and HasFallbackTx is a sealed marker trait for receiver states that hold a verified broadcastable fallback tx. Does that sound better?

@spacebear21 spacebear21 force-pushed the fallback-typestate branch from 56641ee to e6828cc Compare May 21, 2026 00:39
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 e6828cc to 21722b1 Compare May 21, 2026 02:56
@spacebear21 spacebear21 marked this pull request as ready for review May 21, 2026 02:56
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