Skip to content

Commit c379946

Browse files
unblock send gate on stale payjoin session cleanup failure
1 parent 0583ae1 commit c379946

2 files changed

Lines changed: 100 additions & 18 deletions

File tree

rust/src/manager/wallet_manager/actor/transactions.rs

Lines changed: 57 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -443,10 +443,39 @@ impl WalletActor {
443443
Ok(None) => {}
444444

445445
Ok(Some(_)) => {
446-
return Produces::ok(Err(Error::SignAndBroadcastError(
447-
"a previous payjoin session is pending recovery; please try again later"
448-
.to_string(),
449-
)));
446+
// If the session has a terminal marker and the tx is already in the local
447+
// wallet, the only remaining work is session cleanup — no network needed.
448+
// Attempt it here so this send can proceed without requiring a restart.
449+
let persister = PayjoinSessionPersister::new(self.db.clone());
450+
let can_cleanup = persister
451+
.pending_txid()
452+
.is_some_and(|txid| self.wallet.bdk.get_tx(txid).is_some());
453+
454+
if !can_cleanup {
455+
return Produces::ok(Err(Error::SignAndBroadcastError(
456+
"a previous payjoin session is pending recovery; please try again later"
457+
.to_string(),
458+
)));
459+
}
460+
461+
// Retry persistence before cleanup — a prior broadcast may have applied the tx
462+
// in memory without flushing to disk; only drop the session once it is durable.
463+
if let Err(error) = self.wallet.persist() {
464+
warn!("failed to persist wallet at send gate before payjoin cleanup: {error}");
465+
return Produces::ok(Err(Error::SignAndBroadcastError(
466+
"a previous payjoin session is pending cleanup; please try again later"
467+
.to_string(),
468+
)));
469+
}
470+
471+
if let Err(error) = self.db.delete_payjoin_sender_session() {
472+
warn!("payjoin session cleanup at send gate failed: {error}");
473+
return Produces::ok(Err(Error::SignAndBroadcastError(
474+
"a previous payjoin session is pending cleanup; please try again later"
475+
.to_string(),
476+
)));
477+
}
478+
// record cleared — fall through to allow this send
450479
}
451480

452481
Err(error) => {
@@ -713,9 +742,13 @@ impl WalletActor {
713742
return;
714743
}
715744

716-
if let Err(error) = self.db.delete_payjoin_sender_session() {
717-
warn!("failed to clear payjoin session record: {error}");
718-
}
745+
let session_cleared = match self.db.delete_payjoin_sender_session() {
746+
Ok(()) => true,
747+
Err(error) => {
748+
warn!("failed to clear payjoin session record: {error}");
749+
false
750+
}
751+
};
719752

720753
let balance = self.wallet.balance();
721754
self.send(Msg::WalletBalanceChanged(balance.into()));
@@ -724,7 +757,17 @@ impl WalletActor {
724757
self.send(Msg::UpdatedTransactions(transactions));
725758

726759
send!(self.addr.start_transaction_watcher(txid));
727-
self.send(Msg::PayjoinTxBroadcast);
760+
761+
if session_cleared {
762+
self.send(Msg::PayjoinTxBroadcast);
763+
} else {
764+
// TX is broadcast and wallet-persisted, but the session record remains.
765+
// initiate_payment will reject new sends until the record is gone.
766+
// The next startup will clear it automatically via the wallet pre-check.
767+
self.send(Msg::SendFlowError(SendFlowErrorAlert::SignAndBroadcast(
768+
"transaction was broadcast; restart the app to unlock sending".to_string(),
769+
)));
770+
}
728771
}
729772

730773
/// Broadcasts `tx` and calls `finalize_payjoin_terminal` on success. On broadcast failure,
@@ -752,10 +795,12 @@ impl WalletActor {
752795

753796
let node_client = self.node_client().await.ok().cloned();
754797

755-
let already_known = if let Some(client) = node_client {
756-
matches!(client.get_transaction(txid).await, Ok(Some(ref found)) if found.compute_txid() == txid)
757-
} else {
758-
false
798+
let already_known = match node_client {
799+
None => false,
800+
Some(client) => {
801+
let result = client.get_transaction(txid).await;
802+
matches!(result, Ok(Some(ref found)) if found.compute_txid() == txid)
803+
}
759804
};
760805

761806
if already_known {

rust/src/manager/wallet_manager/payjoin.rs

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,25 @@ impl PayjoinSessionPersister {
136136
};
137137
self.db.set_payjoin_sender_session(session)
138138
}
139+
140+
/// Returns the txid committed in the session's terminal marker, if any.
141+
/// Used by the send gate to detect stale-cleanup state without a network call.
142+
pub(crate) fn pending_txid(&self) -> Option<bitcoin::Txid> {
143+
let session = self.db.get_payjoin_sender_session().ok()??;
144+
match &session.pending_action {
145+
None => None,
146+
Some(PendingAction::BroadcastFallback) => {
147+
consensus::deserialize::<BdkTransaction>(&session.fallback_tx)
148+
.ok()
149+
.map(|tx| tx.compute_txid())
150+
}
151+
Some(PendingAction::BroadcastProposal { transaction }) => {
152+
consensus::deserialize::<BdkTransaction>(transaction)
153+
.ok()
154+
.map(|tx| tx.compute_txid())
155+
}
156+
}
157+
}
139158
}
140159

141160
impl SessionPersister for PayjoinSessionPersister {
@@ -704,7 +723,9 @@ pub(crate) fn resume_session(
704723
Ok(None) => return SessionResumption::None,
705724
Err(error) => {
706725
error!("failed to read payjoin session record: {error}");
707-
return SessionResumption::None;
726+
return SessionResumption::ReportError {
727+
message: "could not read payjoin session; reopen the wallet to retry".to_string(),
728+
};
708729
}
709730
};
710731

@@ -764,16 +785,32 @@ pub(crate) fn resume_session(
764785
}
765786
}
766787

767-
/// Recovers the fallback tx stored in the session record when replay is not possible
788+
/// Recovers the fallback tx from the session record when replay is not possible.
768789
fn fallback_from_record(db: &WalletDataDb, record: PayjoinSenderSession) -> SessionResumption {
769790
match consensus::deserialize(&record.fallback_tx) {
770791
Ok(fallback_tx) => SessionResumption::BroadcastFallback { fallback_tx },
792+
793+
Err(error)
794+
if matches!(record.pending_action, Some(PendingAction::BroadcastFallback)) =>
795+
{
796+
// Marker was written before broadcast, so the fallback may have reached the node.
797+
// Retain the record — we can't know the outcome.
798+
error!("committed payjoin fallback tx is unreadable; retaining session record: {error}");
799+
SessionResumption::ReportError {
800+
message: "payjoin fallback data is unreadable — check your transaction history before retrying".to_string(),
801+
}
802+
}
803+
771804
Err(error) => {
772-
error!("stored payjoin fallback tx is corrupt, abandoning session: {error}");
773-
if let Err(error) = db.delete_payjoin_sender_session() {
774-
warn!("failed to clear corrupt payjoin session record: {error}");
805+
// No terminal marker means the fallback was never dispatched; safe to clear.
806+
error!("payjoin fallback tx is corrupt, clearing session: {error}");
807+
if let Err(delete_error) = db.delete_payjoin_sender_session() {
808+
warn!("failed to clear corrupt payjoin session record: {delete_error}");
809+
}
810+
SessionResumption::ReportError {
811+
message: "saved payjoin recovery data was unreadable; the payment was not sent"
812+
.to_string(),
775813
}
776-
SessionResumption::None
777814
}
778815
}
779816
}

0 commit comments

Comments
 (0)