Skip to content

Add v2 receiver fallback subcommand#1542

Draft
DanGould wants to merge 2 commits into
payjoin:masterfrom
DanGould:receiver-fallback
Draft

Add v2 receiver fallback subcommand#1542
DanGould wants to merge 2 commits into
payjoin:masterfrom
DanGould:receiver-fallback

Conversation

@DanGould
Copy link
Copy Markdown
Contributor

@DanGould DanGould commented May 8, 2026

Mirrors the sender-side fallback that shipped in PR #1510. The existing Fallback
subcommand now dispatches to either sender or receiver flow. Session ids on the CLI
take an explicit side prefix (s<n> for send, r<n> for receive) — bare numeric
input is rejected to prevent silent mis-routing when both tables hold the same
auto-incremented id.

The library gains Receiver<S: State>::broadcast_fallback(), a terminal transition
that mirrors Receiver::cancel() but records SessionOutcome::FallbackBroadcasted
instead of Cancel, returning the fallback transaction so the caller can broadcast
it.

The App trait gains fallback(SessionRef) as the unified entry point that parses
the prefix and routes; fallback_sender and fallback_receiver stay as direct entry
points.

Receivers want a way to recover funds when a v2 payjoin session gets stuck after the
sender has posted but before the receiver can finalize and post a proposal. Without
this command, operators have no programmatic path from a stuck session to a broadcast
original.

Notes for reviewers

  • Receiver-side SessionId typing follows when fallback-sender-session-id merges
    (this branch ships with i64 to match current master).
  • The new e2e receiver_fallback_v2 was structurally checked but unverified at
    runtime in the agent sandbox: existing baseline tests sender_fallback_v2 and
    send_receive_payjoin_v2 fail identically on master in that environment due to
    in-process OHTTP relay 403s. CI should confirm.
  • Auto-broadcast on session expiry was prototyped and dropped — receiver fallback is
    manual-only, consistent with v2's interactive spirit.
    Disclosure: co-authored by Claude Opus 4.7

DanGould added 2 commits May 7, 2026 02:08
Mirror the sender-side fallback that shipped in PR payjoin#1510. The
existing `Fallback { session_id }` subcommand now dispatches to
either sender or receiver flow based on which DB table the
session_id belongs to: sender table first (existing behavior
preserved), then receiver table, else error.

The library gains `Receiver<S: State>::broadcast_fallback()`, a
terminal transition that mirrors `Receiver::cancel()` but records
`SessionOutcome::FallbackBroadcasted` instead of `Cancel`,
returning the fallback transaction so the caller can broadcast it.

The `App` trait gains `fallback(session_id)` as the unified entry
point. `fallback_sender` and `fallback_receiver` stay as separate
trait methods for direct invocation; `fallback` is the dispatcher
that consults the new `Database::has_send_session` and
`Database::has_recv_session` lookups before routing. v1 implements
all three trait methods as bail-outs with the same wording.

The v2 `fallback_receiver` implementation replays the receiver
event log; if the session is closed-success or already-broadcast
it no-ops with an explanatory message, otherwise it broadcasts
`SessionHistory::fallback_tx()` via the wallet, saves the
`Closed(FallbackBroadcasted)` event, and closes the session.

The new `receiver_fallback_v2` e2e test in payjoin-cli mirrors
`sender_fallback_v2`: drive a v2 receiver past the broadcast
suitability check via SIGKILL of the resume process, then invoke
`fallback` against the receiver DB and assert the original lands
in the regtest mempool.

Receivers want a way to recover funds when a v2 payjoin session
gets stuck after the sender has posted but before the receiver can
finalize and post a proposal. Without this command, operators have
no programmatic path from a stuck session to a broadcast original.
Sender and receiver session ids auto-increment per table, so
`send_sessions.id = 1` and `recv_sessions.id = 1` routinely coexist.
The previous `payjoin-cli fallback <i64>` dispatcher consulted the
sender table first, then the receiver table, then erred. A user who
meant their receive session 1 silently got their send session 1
fallen-back, broadcasting the wrong original.

Require an authoritative side prefix on every fallback invocation:
`s<n>` for sender, `r<n>` for receiver. Bare numeric input is rejected
with a usage error that names the expected format. The dispatcher
matches on the prefix; no table-existence guess is involved.

Introduce a small `SessionRef` enum with `FromStr` + `Display`
(round-trips, rejects bare numeric, prefix-only, unknown prefix,
negative, internal whitespace, overflow). Wire it through `App::fallback`
on both v1 and v2, and through every operator-visible display site that
currently prints a bare numeric id: `history` rows, the send-payjoin
failure/interrupt hints, the `fallback_sender` / `fallback_receiver`
status messages, and the `process_sender_session` end-of-session hint.
Drop `Database::has_send_session` / `has_recv_session`; they were added
in the previous commit purely to power auto-dispatch and have no other
callers.

Update both fallback e2e tests to feed the prefixed ref.

Without this, every operator with one of each session role open is
one typo away from broadcasting the wrong original.
@coveralls
Copy link
Copy Markdown
Collaborator

coveralls commented May 8, 2026

Coverage Report for CI Build 25547536783

Coverage decreased (-0.07%) to 85.095%

Details

  • Coverage decreased (-0.07%) from the base build.
  • Patch coverage: 31 uncovered changes across 4 files (83 of 114 lines covered, 72.81%).
  • No coverage regressions found.

Uncovered Changes

File Changed Covered %
payjoin-cli/src/app/v2/mod.rs 15 4 26.67%
payjoin-cli/src/cli/session_ref.rs 87 77 88.51%
payjoin/src/core/receive/v2/mod.rs 6 0 0.0%
payjoin-cli/src/app/v1.rs 4 0 0.0%

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 13626
Covered Lines: 11595
Line Coverage: 85.09%
Coverage Strength: 401.91 hits per line

💛 - Coveralls

Copy link
Copy Markdown
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.

ApproachACK. Will take a closer look once its out of draft

| ReceiverSessionOutcome::PayjoinProposalSent => {
println!(
"Session {session_ref} already produced a payjoin proposal. \
Broadcasting the original now would double-spend against it."
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.

Suggested change
Broadcasting the original now would double-spend against it."
Broadcasting the original now may double-spend against it."

Sender may have not broadcasted payjoin yet in the PayjoinProposalSent state

Comment on lines +559 to +563
if let Err(e) = persister
.save_event(ReceiverSessionEvent::Closed(ReceiverSessionOutcome::FallbackBroadcasted))
{
tracing::warn!("Failed to record fallback broadcast for session {session_ref}: {e}");
}
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.

The persistence system was designed to avoid manually pushing events -- and closing the session. Alternatively we could resume the receiver in the monitoring typestate. Or better yet, we replicate the closure strategy i.e

Receiver.fallback(broadcast_tx: |tx| {
    self.wallet().broadcast_tx(&fallback_tx)?;
}) -> MaybeSuccessTransition {
        let fallback_tx = self.fallback_tx();
        broadcast_tx(fallback_tx)?;

         MaybeFatalOrSuccessTransition::success(SessionEvent::Closed(ReceiverSessionOutcome::FallbackBroadcasted));
}

Essentially replicating what we have done for cancel() https://github.com/payjoin/rust-payjoin/blob/master/payjoin/src/core/receive/v2/mod.rs#L351
This shouldn't be a blocker for this PR. Something to follow up on

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.

POC'd here #1543

@arminsabouri arminsabouri mentioned this pull request May 8, 2026
2 tasks
@spacebear21
Copy link
Copy Markdown
Collaborator

spacebear21 commented May 13, 2026

Following up the group PR review discussion.

The problem this is trying to solve is real but I don't think this approach (and the one in #1543) is the right one. My concerns are:

  • it overloads the meaning of SessionOutcome::FallbackBroadcasted which in the current sense can only occur if it was the sender who broadcast the fallback (in a way it's kind of a SenderCancel state or
    similar).
  • it introduces a scenario where we marked the fallback as broadcasted but then a re-org occurs or the tx gets evicted from the mempool. The db state is FallbackBroadcasted but it actually needs to be
    rebroadcasted. State doesn't map to reality
  • the fallback_tx may have been broadcasted manually by the receiver (e.g. if they used cancel()) resulting in the same situation as if they'd called broadcast_fallback(), but the closed session outcome is
    Cancel, not FallbackBroadcasted. Inconsistent states.
  • it doesn't translate to the Failed scenario, e.g. some protocol error occurs, we transition to HasReplyableError, then close the session with SessionOutcome::Failed. It's the implementer's responsibility to broadcast/track the status of the fallback tx, the same footgun we're trying to avoid here with the cancel flow.
  • it's kind of ugly

I went back to "maybe cancel should just transition to Monitor" but I stand by my assessment here and don't think that's the right path either.

I think the real principle we need to observe is: a session must not enter Closed while there's an obligation the caller hasn't acknowledged (to broadcast the fallback tx). The solution to this is to introduce yet another event + typestate that holds the fallback until acknowledged.

  impl<S: State> Receiver<S> {
      pub fn cancel(self) -> NextStateTransition<SessionEvent, Receiver<Cancelled>> {
          let fallback = self.state.fallback_tx();
          NextStateTransition::success(
              SessionEvent::Cancelled { fallback_tx: fallback.clone() },
              Receiver {
                  state: Cancelled { fallback_tx: fallback },
                  session_context: self.session_context,
              },
          )
      }
  }

  #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
  pub struct Cancelled {
      fallback_tx: Option<bitcoin::Transaction>,
  }

  impl Receiver<Cancelled> {
      pub fn fallback_tx(&self) -> Option<&bitcoin::Transaction> {
          self.state.fallback_tx.as_ref()
      }

      /// Caller acknowledges the fallback (broadcast it, or chose not to,
      /// or no fallback existed). Session moves to Closed(Cancel).
      pub fn close(self) -> TerminalTransition<SessionEvent, ()> {
          TerminalTransition::new(SessionEvent::Closed(SessionOutcome::Cancel), ())
      }
  }

The contract between payjoin and the wallet is: A session in ReceiveSession::Cancelled(_) has a pending fallback handoff. The wallet must inspect fallback_tx(), take whatever action it deems appropriate (broadcast, persist for later, discard), then call close() to release the obligation. Until close(), the session shows up in resume.

This same pattern can be extended to the protocol Failure mode. We can do this with a standalone typestate for each case as illustrated below, or with a generic typestate like HasFallback {fallback_tx: Option<Transaction>}, pending_outcome: SessionOutcome}. Either works. It also mirrors to the Sender side.

Receiver:

stateDiagram-v2
      [*] --> Initialized

      Initialized --> UncheckedOriginalPayload: retrieved original
      Initialized --> ClosedCancel: cancel()

      UncheckedOriginalPayload --> Protocol: check_broadcast_suitability
      state "Protocol Path<br>(MaybeInputsOwned ─ MaybeInputsSeen ─ OutputsUnknown ─<br>WantsOutputs ─ WantsInputs ─ WantsFeeRange ─ ProvisionalProposal)" as Protocol
      Protocol --> PayjoinProposal: finalize_proposal
      PayjoinProposal --> Monitor: process_response (POST ok)

      Monitor --> ClosedSuccess: check_payment<br>(payjoin observed)
      Monitor --> ClosedFallbackBroadcasted: check_payment<br>(fallback observed)
      Monitor --> ClosedPayjoinProposalSent: non-SegWit<br>(unmonitorable)

      UncheckedOriginalPayload --> Cancelled: cancel()
      Protocol --> Cancelled: cancel()
      PayjoinProposal --> Cancelled: cancel()
      Monitor --> Cancelled: cancel()

      UncheckedOriginalPayload --> HasReplyableError: fatal error
      Protocol --> HasReplyableError: fatal error
      PayjoinProposal --> HasReplyableError: fatal error

      HasReplyableError --> Failed: process_error_response<br>(had fallback)
      HasReplyableError --> Cancelled: cancel()<br>(had fallback)
      HasReplyableError --> ClosedFailure: process_error_response<br>(no fallback)
      HasReplyableError --> ClosedCancel: cancel()<br>(no fallback)

      Cancelled --> ClosedCancel: close()
      Failed --> ClosedFailure: close()

      state "Closed(Cancel)" as ClosedCancel
      state "Closed(Failure)" as ClosedFailure
      state "Closed(Success)" as ClosedSuccess
      state "Closed(FallbackBroadcasted)" as ClosedFallbackBroadcasted
      state "Closed(PayjoinProposalSent)" as ClosedPayjoinProposalSent
      state "Cancelled<br>{fallback_tx}" as Cancelled
      state "Failed<br>{fallback_tx}" as Failed

      ClosedCancel --> [*]
      ClosedFailure --> [*]
      ClosedSuccess --> [*]
      ClosedFallbackBroadcasted --> [*]
      ClosedPayjoinProposalSent --> [*]
Loading

Sender:

 stateDiagram-v2
      [*] --> WithReplyKey: SenderBuilder.build()

      WithReplyKey --> PollingForProposal: post original PSBT
      PollingForProposal --> ClosedSuccess: process_response<br>(proposal received)

      WithReplyKey --> Cancelled: cancel()
      PollingForProposal --> Cancelled: cancel()

      WithReplyKey --> Failed: fatal error
      PollingForProposal --> Failed: fatal error

      Cancelled --> ClosedCancel: close()
      Failed --> ClosedFailure: close()

      state "Closed(Success(psbt))" as ClosedSuccess
      state "Closed(Cancel)" as ClosedCancel
      state "Closed(Failure)" as ClosedFailure
      state "Cancelled<br>{fallback_tx}" as Cancelled
      state "Failed<br>{fallback_tx}" as Failed

      ClosedSuccess --> [*]
      ClosedCancel --> [*]
      ClosedFailure --> [*]
Loading

@arminsabouri
Copy link
Copy Markdown
Collaborator

pub struct Cancelled {
fallback_tx: Optionbitcoin::Transaction,
}

This shouldnt be optional. We should just avoid transitioning to this typestate if the original proposal was never obtained. If a reciever cancled before the second typestate, just close -- there is no other recourse.

@spacebear21
Copy link
Copy Markdown
Collaborator

spacebear21 commented May 13, 2026

This shouldnt be optional. We should just avoid transitioning to this typestate if the original proposal was never obtained. If a reciever cancled before the second typestate, just close -- there is no other recourse.

Yes, looks like I pasted an earlier iteration of my proposal in the code snippet but the diagram does show Initialized going directly to closed.

After thinking about this some more I think all we need is one new typestate e.g. HasFallback { fallback_tx: Transaction } that must be reached either with cancel() or a fatal error transition. To avoid having to pass a pending_outcome to HasFallback, the SessionOutcome::Cancel/Failure variants can be probably be collapsed into one variant (Aborted?), because how we got there can be inferred by the event log itself. These outcome variants are now actual session events emitted by cancel() or the fatal error site.

Scenarios:

  • Cancel from a state that doesn't have a fallback (Initialized, and HasReplyableError if it came from a failed check_broadcast_suitability check): emit Closed directly.
  • Cancel from any state that has a fallback: emit Cancelled, transition to HasFallback.
  • Fatal error from a state with no fallback: emit Closed directly.
  • Fatal error from any state that has a fallback: two sub-cases:
    • receiver entered HasReplyableError with fallback_tx: Some(...)
      • process_error_response success: emit Failed, transition to HasFallback.
      • cancel() directly (skip error response if directory is unreachable or the receiver just wants to bail): emit Cancelled, transition to HasFallback.
    • every other fatal arm: emit Failed and transition to HasFallback. These are the fatal branches in PayjoinProposal::process_response, WithReplyKey::process_response and PollingForProposal::process_response (due to network/directory errors) that currently emit a Closed(Failure).

We may need some new trait bounds to actually enforce all of this at compile-time, but it seems feasible.

@arminsabouri arminsabouri mentioned this pull request May 14, 2026
2 tasks
@spacebear21 spacebear21 mentioned this pull request May 14, 2026
2 tasks
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