Skip to content

Add v2 receiver fallback subcommand#1542

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

Add v2 receiver fallback subcommand#1542
DanGould wants to merge 2 commits intopayjoin: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
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