Add v2 receiver fallback subcommand#1542
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.
Coverage Report for CI Build 25547536783Coverage decreased (-0.07%) to 85.095%Details
Uncovered Changes
Coverage RegressionsNo coverage regressions found. Coverage Stats
💛 - Coveralls |
| | ReceiverSessionOutcome::PayjoinProposalSent => { | ||
| println!( | ||
| "Session {session_ref} already produced a payjoin proposal. \ | ||
| Broadcasting the original now would double-spend against it." |
There was a problem hiding this comment.
| 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
| if let Err(e) = persister | ||
| .save_event(ReceiverSessionEvent::Closed(ReceiverSessionOutcome::FallbackBroadcasted)) | ||
| { | ||
| tracing::warn!("Failed to record fallback broadcast for session {session_ref}: {e}"); | ||
| } |
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
|
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:
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. The contract between payjoin and the wallet is: A session in 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 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 --> [*]
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 --> [*]
|
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 After thinking about this some more I think all we need is one new typestate e.g. Scenarios:
We may need some new trait bounds to actually enforce all of this at compile-time, but it seems feasible. |
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