Skip to content
This repository was archived by the owner on Feb 3, 2025. It is now read-only.

Commit f08685f

Browse files
committed
Track pending payjoin transactions
Both sender original PSBT and payjoin proposal PSBT are tracked as pending. Only the one that confirms proceeds to live in the wallet forever.
1 parent 1866eb4 commit f08685f

File tree

4 files changed

+79
-17
lines changed

4 files changed

+79
-17
lines changed

mutiny-core/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1518,7 +1518,7 @@ impl<S: MutinyStorage> MutinyWallet<S> {
15181518
let session = self
15191519
.node_manager
15201520
.storage
1521-
.persist_payjoin(enrolled.clone())?;
1521+
.persist_enrolled(enrolled.clone())?;
15221522
let pj_uri = session.enrolled.fallback_target();
15231523
self.node_manager.spawn_payjoin_receiver(session);
15241524
let ohttp = base64::encode_config(

mutiny-core/src/nodemanager.rs

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -767,7 +767,16 @@ impl<S: MutinyStorage> NodeManager<S> {
767767
.map_err(|_| MutinyError::IncorrectNetwork)?;
768768
let address = uri.address.clone();
769769
let original_psbt = self.wallet.create_signed_psbt(address, amount, fee_rate)?;
770-
// TODO ensure this creates a pending tx in the UI. Ensure locked UTXO.
770+
// Track this transaction in the wallet so it shows as an ActivityItem in UI.
771+
// We'll cancel it if and when this original_psbt fallback is replaced with a received payjoin.
772+
self.wallet
773+
.insert_tx(
774+
original_psbt.clone().extract_tx(),
775+
ConfirmationTime::unconfirmed(crate::utils::now().as_secs()),
776+
None,
777+
)
778+
.await?;
779+
771780
let fee_rate = if let Some(rate) = fee_rate {
772781
FeeRate::from_sat_per_vb(rate)
773782
} else {
@@ -798,6 +807,7 @@ impl<S: MutinyStorage> NodeManager<S> {
798807
let proposal_psbt = match Self::poll_payjoin_sender(stop, req_ctx).await {
799808
Ok(psbt) => psbt,
800809
Err(e) => {
810+
// self.wallet cancel_tx
801811
log_error!(logger, "Error polling payjoin sender: {e}");
802812
return;
803813
}
@@ -867,11 +877,13 @@ impl<S: MutinyStorage> NodeManager<S> {
867877
labels: Vec<String>,
868878
) -> Result<Txid, MutinyError> {
869879
log_debug!(logger, "Sending payjoin..");
880+
let original_tx = original_psbt.clone().extract_tx();
870881
let tx = wallet
871882
.send_payjoin(original_psbt, proposal_psbt, labels)
872883
.await?;
873884
let txid = tx.txid();
874885
wallet.broadcast_transaction(tx).await?;
886+
wallet.cancel_tx(&original_tx)?;
875887
log_info!(logger, "Payjoin broadcast! TXID: {txid}");
876888
Ok(txid)
877889
}
@@ -899,12 +911,18 @@ impl<S: MutinyStorage> NodeManager<S> {
899911
let http_client = reqwest::Client::builder()
900912
.build()
901913
.map_err(PayjoinError::Reqwest)?;
902-
let proposal: payjoin::receive::v2::UncheckedProposal =
903-
Self::poll_for_fallback_psbt(stop, storage, &http_client, &mut session)
904-
.await
905-
.map_err(|e| PayjoinError::ReceiverStateMachine(e.to_string()))?;
914+
let proposal: payjoin::receive::v2::UncheckedProposal = Self::poll_for_fallback_psbt(
915+
stop,
916+
wallet.clone(),
917+
storage.clone(),
918+
&http_client,
919+
&mut session,
920+
)
921+
.await
922+
.map_err(|e| PayjoinError::ReceiverStateMachine(e.to_string()))?;
906923
let mut payjoin_proposal = wallet
907924
.process_payjoin_proposal(proposal)
925+
.await
908926
.map_err(|e| PayjoinError::ReceiverStateMachine(e.to_string()))?;
909927

910928
let (req, ohttp_ctx) = payjoin_proposal
@@ -922,11 +940,23 @@ impl<S: MutinyStorage> NodeManager<S> {
922940
let _res = payjoin_proposal
923941
.deserialize_res(res.to_vec(), ohttp_ctx)
924942
.map_err(|e| PayjoinError::ReceiverStateMachine(e.to_string()))?;
925-
Ok(payjoin_proposal.psbt().clone().extract_tx().txid())
943+
let payjoin_tx = payjoin_proposal.psbt().clone().extract_tx();
944+
let payjoin_txid = payjoin_tx.txid();
945+
wallet
946+
.insert_tx(
947+
payjoin_tx.clone(),
948+
ConfirmationTime::unconfirmed(utils::now().as_secs()),
949+
None,
950+
)
951+
.await?;
952+
session.payjoin_tx = Some(payjoin_tx);
953+
storage.update_session(session)?;
954+
Ok(payjoin_txid)
926955
}
927956

928957
async fn poll_for_fallback_psbt(
929958
stop: Arc<AtomicBool>,
959+
wallet: Arc<OnChainWallet<S>>,
930960
storage: Arc<S>,
931961
client: &reqwest::Client,
932962
session: &mut crate::payjoin::Session,
@@ -937,6 +967,11 @@ impl<S: MutinyStorage> NodeManager<S> {
937967
}
938968

939969
if session.expiry < utils::now() {
970+
if let Some(payjoin_tx) = &session.payjoin_tx {
971+
wallet
972+
.cancel_tx(payjoin_tx)
973+
.map_err(|_| crate::payjoin::Error::CancelPayjoinTx)?;
974+
}
940975
let _ = storage.delete_payjoin(&session.enrolled.pubkey());
941976
return Err(crate::payjoin::Error::SessionExpired);
942977
}

mutiny-core/src/onchain.rs

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,12 @@ impl<S: MutinyStorage> OnChainWallet<S> {
355355
Ok(())
356356
}
357357

358+
pub(crate) fn cancel_tx(&self, tx: &Transaction) -> Result<(), MutinyError> {
359+
let mut wallet = self.wallet.try_write()?;
360+
wallet.cancel_tx(tx);
361+
Ok(())
362+
}
363+
358364
fn is_mine(&self, script: &Script) -> Result<bool, MutinyError> {
359365
Ok(self.wallet.try_read()?.is_mine(script))
360366
}
@@ -363,7 +369,7 @@ impl<S: MutinyStorage> OnChainWallet<S> {
363369
Ok(self.wallet.try_read()?.list_unspent().collect())
364370
}
365371

366-
pub fn process_payjoin_proposal(
372+
pub async fn process_payjoin_proposal(
367373
&self,
368374
proposal: payjoin::receive::v2::UncheckedProposal,
369375
) -> Result<payjoin::receive::v2::PayjoinProposal, payjoin::Error> {
@@ -407,21 +413,26 @@ impl<S: MutinyStorage> OnChainWallet<S> {
407413
let payjoin_proposal = provisional_payjoin.finalize_proposal(
408414
|psbt| {
409415
let mut psbt = psbt.clone();
410-
let wallet = self
411-
.wallet
412-
.try_read()
413-
.map_err(|_| Error::Server(MutinyError::WalletSigningFailed.into()))?;
414-
wallet
416+
self.wallet
417+
.try_write()
418+
.map_err(|_| Error::Server(MutinyError::WalletSigningFailed.into()))?
415419
.sign(&mut psbt, SignOptions::default())
416420
.map_err(|_| Error::Server(MutinyError::WalletSigningFailed.into()))?;
417421
Ok(psbt)
418422
},
419423
Some(min_pj_fee_rate),
420424
)?;
421-
let payjoin_proposal_psbt = payjoin_proposal.psbt();
425+
let payjoin_psbt_tx = payjoin_proposal.psbt().clone().extract_tx();
426+
self.insert_tx(
427+
payjoin_psbt_tx,
428+
ConfirmationTime::unconfirmed(crate::utils::now().as_secs()),
429+
None,
430+
)
431+
.await
432+
.map_err(|_| Error::Server(MutinyError::WalletOperationFailed.into()))?;
422433
log::debug!(
423434
"Receiver's Payjoin proposal PSBT Rsponse: {:#?}",
424-
payjoin_proposal_psbt
435+
payjoin_proposal.psbt()
425436
);
426437
Ok(payjoin_proposal)
427438
}

mutiny-core/src/payjoin.rs

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ use std::collections::HashMap;
22

33
use crate::error::MutinyError;
44
use crate::storage::MutinyStorage;
5+
use bitcoin::Transaction;
56
use core::time::Duration;
67
use hex_conservative::DisplayHex;
78
use once_cell::sync::Lazy;
@@ -22,6 +23,7 @@ pub(crate) static PAYJOIN_DIR: Lazy<Url> =
2223
pub struct Session {
2324
pub enrolled: Enrolled,
2425
pub expiry: Duration,
26+
pub payjoin_tx: Option<Transaction>,
2527
}
2628

2729
impl Session {
@@ -32,7 +34,8 @@ impl Session {
3234
pub trait PayjoinStorage {
3335
fn get_payjoin(&self, id: &[u8; 33]) -> Result<Option<Session>, MutinyError>;
3436
fn get_payjoins(&self) -> Result<Vec<Session>, MutinyError>;
35-
fn persist_payjoin(&self, session: Enrolled) -> Result<Session, MutinyError>;
37+
fn persist_enrolled(&self, session: Enrolled) -> Result<Session, MutinyError>;
38+
fn update_session(&self, session: Session) -> Result<(), MutinyError>;
3639
fn delete_payjoin(&self, id: &[u8; 33]) -> Result<(), MutinyError>;
3740
}
3841

@@ -53,16 +56,21 @@ impl<S: MutinyStorage> PayjoinStorage for S {
5356
Ok(map.values().map(|v| v.to_owned()).collect())
5457
}
5558

56-
fn persist_payjoin(&self, enrolled: Enrolled) -> Result<Session, MutinyError> {
59+
fn persist_enrolled(&self, enrolled: Enrolled) -> Result<Session, MutinyError> {
5760
let in_24_hours = crate::utils::now() + Duration::from_secs(60 * 60 * 24);
5861
let session = Session {
5962
enrolled,
6063
expiry: in_24_hours,
64+
payjoin_tx: None,
6165
};
6266
self.set_data(get_payjoin_key(&session.pubkey()), session.clone(), None)
6367
.map(|_| session)
6468
}
6569

70+
fn update_session(&self, session: Session) -> Result<(), MutinyError> {
71+
self.set_data(get_payjoin_key(&session.pubkey()), session, None)
72+
}
73+
6674
fn delete_payjoin(&self, id: &[u8; 33]) -> Result<(), MutinyError> {
6775
self.delete(&[get_payjoin_key(id)])
6876
}
@@ -88,6 +96,10 @@ pub enum Error {
8896
OhttpDecodeFailed,
8997
Shutdown,
9098
SessionExpired,
99+
BadDirectoryHost,
100+
BadOhttpWsHost,
101+
RequestFailed(String),
102+
CancelPayjoinTx,
91103
}
92104

93105
impl std::error::Error for Error {}
@@ -101,6 +113,10 @@ impl std::fmt::Display for Error {
101113
Error::OhttpDecodeFailed => write!(f, "Failed to decode ohttp keys"),
102114
Error::Shutdown => write!(f, "Payjoin stopped by application shutdown"),
103115
Error::SessionExpired => write!(f, "Payjoin session expired. Create a new payment request and have the sender try again."),
116+
Error::BadDirectoryHost => write!(f, "Bad directory host"),
117+
Error::BadOhttpWsHost => write!(f, "Bad ohttp ws host"),
118+
Error::RequestFailed(e) => write!(f, "Request failed: {}", e),
119+
Error::CancelPayjoinTx => write!(f, "Failed to cancel payjoin tx in wallet"),
104120
}
105121
}
106122
}

0 commit comments

Comments
 (0)