Add v2 receiver fallback subcommand#1542
Draft
DanGould wants to merge 2 commits intopayjoin:masterfrom
Draft
Conversation
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.
Collaborator
Coverage Report for CI Build 25547536783Coverage decreased (-0.07%) to 85.095%Details
Uncovered Changes
Coverage RegressionsNo coverage regressions found. Coverage Stats
💛 - Coveralls |
arminsabouri
reviewed
May 8, 2026
| | ReceiverSessionOutcome::PayjoinProposalSent => { | ||
| println!( | ||
| "Session {session_ref} already produced a payjoin proposal. \ | ||
| Broadcasting the original now would double-spend against it." |
Collaborator
There was a problem hiding this comment.
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}"); | ||
| } |
Collaborator
There was a problem hiding this comment.
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
2 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Mirrors the sender-side fallback that shipped in PR #1510. The existing
Fallbacksubcommand 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 numericinput 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 transitionthat mirrors
Receiver::cancel()but recordsSessionOutcome::FallbackBroadcastedinstead of
Cancel, returning the fallback transaction so the caller can broadcastit.
The
Apptrait gainsfallback(SessionRef)as the unified entry point that parsesthe prefix and routes;
fallback_senderandfallback_receiverstay as direct entrypoints.
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
SessionIdtyping follows whenfallback-sender-session-idmerges(this branch ships with
i64to match current master).receiver_fallback_v2was structurally checked but unverified atruntime in the agent sandbox: existing baseline tests
sender_fallback_v2andsend_receive_payjoin_v2fail identically on master in that environment due toin-process OHTTP relay 403s. CI should confirm.
manual-only, consistent with v2's interactive spirit.
Disclosure: co-authored by Claude Opus 4.7