Skip to content

Commit 7117416

Browse files
committed
Skip check_payment in Receiver<Monitor> if the sender is using non-segwit address
The previous implementation of the check_payment function assumed that if the outpoints of the Payjoin transaction have been removed from the UTXO set, it is an indication of the Payjoin being broadcasted by the sender. Moreover, it relied on the same closure to detect whether there is a double-spend attempt from the sender, if only a subset of the outpoints have been spent. Both of the usages of the outpoint closure is incorrect. If the sender does RBF on the fallback transaction, this would change the transaction ID and cause the previous implementation to incorrectly detect double-spend. Moreover, the sender can use some of the UTXOs in the Payjoin session if they wish without necessarily "attacking" the receiver. This change removes the outpoint closure, and instead skips the check if the fallback transaction has any inputs which does not have witness data. This assumes that the fallback transaction has been signed which is a certainty at this point in the Payjoin session.
1 parent 77b7594 commit 7117416

File tree

6 files changed

+78
-143
lines changed

6 files changed

+78
-143
lines changed

payjoin-cli/src/app/v2/mod.rs

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@ impl StatusText for ReceiveSession {
8282
ReceiverSessionOutcome::Success(_) => "Session success",
8383
ReceiverSessionOutcome::Cancel => "Session cancelled",
8484
ReceiverSessionOutcome::FallbackBroadcasted => "Fallback broadcasted",
85+
ReceiverSessionOutcome::BroadcastStatusUnknown =>
86+
"Broadcast status of Payjoin transaction unknown",
8587
},
8688
}
8789
}
@@ -778,18 +780,11 @@ impl App {
778780
loop {
779781
interval.tick().await;
780782
let check_result = proposal
781-
.check_payment(
782-
|txid| {
783-
self.wallet()
784-
.get_raw_transaction(&txid)
785-
.map_err(|e| ImplementationError::from(e.into_boxed_dyn_error()))
786-
},
787-
|outpoint| {
788-
self.wallet()
789-
.is_outpoint_spent(&outpoint)
790-
.map_err(|e| ImplementationError::from(e.into_boxed_dyn_error()))
791-
},
792-
)
783+
.check_payment(|txid| {
784+
self.wallet()
785+
.get_raw_transaction(&txid)
786+
.map_err(|e| ImplementationError::from(e.into_boxed_dyn_error()))
787+
})
793788
.save(persister);
794789

795790
match check_result {

payjoin-cli/src/app/wallet.rs

Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -134,26 +134,6 @@ impl BitcoindWallet {
134134
}
135135
}
136136

137-
#[cfg(feature = "v2")]
138-
pub fn is_outpoint_spent(&self, outpoint: &OutPoint) -> Result<bool> {
139-
let _ = tokio::task::block_in_place(|| {
140-
tokio::runtime::Handle::current()
141-
// Note: explicitly ignore txouts in the mempool. Those should be considered spent for our purposes
142-
.block_on(async {
143-
match self.rpc.get_tx_out(&outpoint.txid, outpoint.vout, false).await {
144-
Ok(_) => Ok(true),
145-
Err(e) =>
146-
if e.is_missing_or_invalid_input() {
147-
Ok(false)
148-
} else {
149-
Err(e)
150-
},
151-
}
152-
})
153-
})?;
154-
Ok(true)
155-
}
156-
157137
#[cfg(feature = "v2")]
158138
pub fn get_raw_transaction(
159139
&self,

payjoin-ffi/src/receive/mod.rs

Lines changed: 7 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1136,24 +1136,13 @@ fn try_deserialize_tx(
11361136

11371137
#[uniffi::export]
11381138
impl Monitor {
1139-
pub fn monitor(
1140-
&self,
1141-
transaction_exists: Arc<dyn TransactionExists>,
1142-
outpoint_spent: Arc<dyn OutpointSpent>,
1143-
) -> MonitorTransition {
1144-
MonitorTransition(Arc::new(RwLock::new(Some(self.0.clone().check_payment(
1145-
|txid| {
1146-
transaction_exists
1147-
.callback(txid.to_string())
1148-
.and_then(|buf| buf.map(try_deserialize_tx).transpose())
1149-
.map_err(|e| ImplementationError::new(e).into())
1150-
},
1151-
|outpoint| {
1152-
outpoint_spent
1153-
.callback(outpoint.into())
1154-
.map_err(|e| ImplementationError::new(e).into())
1155-
},
1156-
)))))
1139+
pub fn monitor(&self, transaction_exists: Arc<dyn TransactionExists>) -> MonitorTransition {
1140+
MonitorTransition(Arc::new(RwLock::new(Some(self.0.clone().check_payment(|txid| {
1141+
transaction_exists
1142+
.callback(txid.to_string())
1143+
.and_then(|buf| buf.map(try_deserialize_tx).transpose())
1144+
.map_err(|e| ImplementationError::new(e).into())
1145+
})))))
11571146
}
11581147
}
11591148

payjoin/src/core/receive/v2/mod.rs

Lines changed: 51 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -1223,11 +1223,29 @@ impl Receiver<Monitor> {
12231223
pub fn check_payment(
12241224
&self,
12251225
transaction_exists: impl Fn(Txid) -> Result<Option<bitcoin::Transaction>, ImplementationError>,
1226-
outpoint_spent: impl Fn(OutPoint) -> Result<bool, ImplementationError>,
12271226
) -> MaybeFatalOrSuccessTransition<SessionEvent, Self, Error> {
1227+
let fallback_tx = self
1228+
.state
1229+
.psbt_context
1230+
.original_psbt
1231+
.clone()
1232+
.extract_tx_fee_rate_limit()
1233+
.expect("fallback transaction should be in the receiver context");
1234+
1235+
// If the fallback transaction included any non-SegWit inputs, then the transaction ID of
1236+
// the Payjoin proposal is going to change when the sender signs their non-SegWit address
1237+
// one more time. The receiver cannot monitor the broadcast, and should conclude the sesion.
1238+
if fallback_tx.input.iter().any(|txin| txin.witness.is_empty()) {
1239+
return MaybeFatalOrSuccessTransition::success(SessionEvent::Closed(
1240+
SessionOutcome::BroadcastStatusUnknown,
1241+
));
1242+
}
1243+
12281244
let payjoin_proposal = &self.state.psbt_context.payjoin_psbt;
12291245
let payjoin_txid = payjoin_proposal.unsigned_tx.compute_txid();
1230-
// If we have a payjoin transaction with segwit inputs, we can check for the txid
1246+
// If the sender is using a SegWit address, then the transaction ID of the Payjoin proposal
1247+
// is not going to change when the sender signs it. So we can use the TXID to determine if
1248+
// the Payjoin proposal has been broadcasted.
12311249
match transaction_exists(payjoin_txid) {
12321250
Ok(Some(tx)) => {
12331251
let tx_id = tx.compute_txid();
@@ -1253,14 +1271,8 @@ impl Receiver<Monitor> {
12531271
Err(e) => return MaybeFatalOrSuccessTransition::transient(Error::Implementation(e)),
12541272
}
12551273

1256-
// Check for fallback being broadcasted
1257-
let fallback_tx = self
1258-
.state
1259-
.psbt_context
1260-
.original_psbt
1261-
.clone()
1262-
.extract_tx_fee_rate_limit()
1263-
.expect("Checked in earlier typestates");
1274+
// If the Payjoin proposal was not found, check the fallback transaction, at it is
1275+
// the second of two transactions whose IDs the receiver is aware of.
12641276
match transaction_exists(fallback_tx.compute_txid()) {
12651277
Ok(Some(_)) =>
12661278
return MaybeFatalOrSuccessTransition::success(SessionEvent::Closed(
@@ -1270,29 +1282,6 @@ impl Receiver<Monitor> {
12701282
Err(e) => return MaybeFatalOrSuccessTransition::transient(Error::Implementation(e)),
12711283
}
12721284

1273-
let mut outpoints_spend = 0;
1274-
for ot in payjoin_proposal.unsigned_tx.input.iter() {
1275-
match outpoint_spent(ot.previous_output) {
1276-
Ok(false) => {}
1277-
Ok(true) => outpoints_spend += 1,
1278-
Err(e) =>
1279-
return MaybeFatalOrSuccessTransition::transient(Error::Implementation(e)),
1280-
}
1281-
}
1282-
1283-
if outpoints_spend == payjoin_proposal.unsigned_tx.input.len() {
1284-
// All the payjoin proposal outpoints were spent. This means our payjoin proposal has non-segwit inputs and is broadcasted.
1285-
return MaybeFatalOrSuccessTransition::success(SessionEvent::Closed(
1286-
// TODO: there seems to be not great way to get the tx of the tx that spent these outpoints.
1287-
SessionOutcome::Success(vec![]),
1288-
));
1289-
} else if outpoints_spend > 0 {
1290-
// Some outpoints were spent but not in the payjoin proposal. This is a double spend.
1291-
return MaybeFatalOrSuccessTransition::success(SessionEvent::Closed(
1292-
SessionOutcome::Failure,
1293-
));
1294-
}
1295-
12961285
MaybeFatalOrSuccessTransition::no_results(self.clone())
12971286
}
12981287
}
@@ -1333,7 +1322,7 @@ pub(crate) fn pj_uri<'a>(
13331322
pub mod test {
13341323
use std::str::FromStr;
13351324

1336-
use bitcoin::FeeRate;
1325+
use bitcoin::{FeeRate, ScriptBuf, Witness};
13371326
use once_cell::sync::Lazy;
13381327
use payjoin_test_utils::{
13391328
BoxError, EXAMPLE_URL, KEM, KEY_ID, ORIGINAL_PSBT, PARSED_ORIGINAL_PSBT,
@@ -1418,7 +1407,7 @@ pub mod test {
14181407
// Nothing was spent, should be in the same state
14191408
let persister = InMemoryTestPersister::default();
14201409
let res = monitor
1421-
.check_payment(|_| Ok(None), |_| Ok(false))
1410+
.check_payment(|_| Ok(None))
14221411
.save(&persister)
14231412
.expect("InMemoryTestPersister shouldn't fail");
14241413
assert!(matches!(res, OptionalTransitionOutcome::Stasis(_)));
@@ -1428,7 +1417,7 @@ pub mod test {
14281417
// Payjoin was broadcasted, should progress to success
14291418
let persister = InMemoryTestPersister::default();
14301419
let res = monitor
1431-
.check_payment(|_| Ok(Some(payjoin_tx.clone())), |_| Ok(false))
1420+
.check_payment(|_| Ok(Some(payjoin_tx.clone())))
14321421
.save(&persister)
14331422
.expect("InMemoryTestPersister shouldn't fail");
14341423

@@ -1446,17 +1435,14 @@ pub mod test {
14461435
// Fallback was broadcasted, should progress to success
14471436
let persister = InMemoryTestPersister::default();
14481437
let res = monitor
1449-
.check_payment(
1450-
|txid| {
1451-
// Emulate if one of the fallback outpoints was double spent
1452-
if txid == original_tx.compute_txid() {
1453-
Ok(Some(original_tx.clone()))
1454-
} else {
1455-
Ok(None)
1456-
}
1457-
},
1458-
|_| Ok(false),
1459-
)
1438+
.check_payment(|txid| {
1439+
// Emulate if one of the fallback outpoints was double spent
1440+
if txid == original_tx.compute_txid() {
1441+
Ok(Some(original_tx.clone()))
1442+
} else {
1443+
Ok(None)
1444+
}
1445+
})
14601446
.save(&persister)
14611447
.expect("InMemoryTestPersister shouldn't fail");
14621448

@@ -1467,49 +1453,35 @@ pub mod test {
14671453
Some(&SessionEvent::Closed(SessionOutcome::FallbackBroadcasted))
14681454
);
14691455

1470-
let persister = InMemoryTestPersister::default();
1471-
let res = monitor
1472-
.check_payment(|_| Ok(None), |_| Ok(true))
1473-
.save(&persister)
1474-
.expect("InMemoryTestPersister shouldn't fail");
1456+
// Fallback transaction is non-SegWit address type, should end the session without checking
1457+
// the network for broadcasts.
1458+
// Not using the test-utils vectors here as they are SegWit.
1459+
let parsed_original_psbt_p2pkh = Psbt::from_str("cHNidP8BAFICAAAAAd5tU7sqAGa46oUVdEfV1HTeVVPYqvSvxy8/dvF3dwpZAQAAAAD9////AUTxBSoBAAAAFgAUhV1NWa6seBB5g6VZC2lnduxfEaUAAAAAAAEA/QoBAgAAAAIT2eO393FPqJ4fw6NH0rXALebtTCderecX0y6DumtjNgAAAAAA/f///5hrwcRiTXqXScbvk3APDdzy162Yj+6JD/iSEO9KYQl+AQAAAGpHMEQCIGcFm57xH5tQvJMipWfzxS7OGRi7+JfTT6WA27kOt8fVAiAp2I3WGdLk3/dVhoVxN6Jl9Wp/xeCIZZ1OTukSs8jszgEhAjjEq9kNnhvQbdVlWsE9QTIe4h39UPQ8flvU5Ivq6DFm/f///wIo3gUqAQAAABl2qRTWng6zTFWPZX1k12UqqBI6kLz8z4isAPIFKgEAAAAZdqkUIz2wzl605b3cg3j72nXReQuXXaWIrGcAAAABB2pHMEQCIEP33+9X/ecNmaiydM54HS+HoHfZygAQ/vMlc5r1IWkeAiA9oKjOVmp+RnrDF4zzHHGtoG1yy1+UWXBNaDiwd0LokgEhAmfCwbIv1mi5psiB3HFqXN1bFAo+goNUPWIso60J1matAAA=").expect("known psbt should parse");
1460+
let parsed_payjoin_proposal_p2pkh: Psbt =
1461+
Psbt::from_str("cHNidP8BAHsCAAAAAphrwcRiTXqXScbvk3APDdzy162Yj+6JD/iSEO9KYQl+AAAAAAD9////3m1TuyoAZrjqhRV0R9XUdN5VU9iq9K/HLz928Xd3ClkBAAAAAP3///8BsOILVAIAAAAWABSFXU1Zrqx4EHmDpVkLaWd27F8RpQAAAAAAAQCgAgAAAAJgEjBIihNzFXar4wIYepzXJwQVpbqZep9GCY8pQCqh3wAAAAAA/f///x8caN/onT7AOPRWJz7vnT6yiNxcsAIs/U3RcgU4kiq4AAAAAAD9////AgDyBSoBAAAAGXapFDGh2kOIa5aNVHT2bHSoFfcawEMiiKyk6QUqAQAAABl2qRQY8AsQvx+jg9NdGUwCuShS3qk2KYisZwAAAAEBIgDyBSoBAAAAGXapFDGh2kOIa5aNVHT2bHSoFfcawEMiiKwBB2pHMEQCICQEE2dMDzlyH3ojsc0l98Da0yd2ARuy5AcWQjlgHHjkAiA70WPB+yQhW5zhsOBTg6qLsi0KzoofRAj1BZFpKT2QwAEhA68L99Q+xdIIp0rinuVDs+4qmqMZwg4E+aqbTQ8RClXLAAEA/QoBAgAAAAIT2eO393FPqJ4fw6NH0rXALebtTCderecX0y6DumtjNgAAAAAA/f///5hrwcRiTXqXScbvk3APDdzy162Yj+6JD/iSEO9KYQl+AQAAAGpHMEQCIGcFm57xH5tQvJMipWfzxS7OGRi7+JfTT6WA27kOt8fVAiAp2I3WGdLk3/dVhoVxN6Jl9Wp/xeCIZZ1OTukSs8jszgEhAjjEq9kNnhvQbdVlWsE9QTIe4h39UPQ8flvU5Ivq6DFm/f///wIo3gUqAQAAABl2qRTWng6zTFWPZX1k12UqqBI6kLz8z4isAPIFKgEAAAAZdqkUIz2wzl605b3cg3j72nXReQuXXaWIrGcAAAAAAA==").expect("known psbt should parse");
14751462

1476-
assert!(matches!(res, OptionalTransitionOutcome::Progress(_)));
1477-
assert!(persister.inner.read().expect("Shouldn't be poisoned").is_closed);
1478-
assert_eq!(persister.inner.read().expect("Shouldn't be poisoned").events.len(), 1);
1463+
let psbt_ctx_p2pkh = PsbtContext {
1464+
original_psbt: parsed_original_psbt_p2pkh.clone(),
1465+
payjoin_psbt: parsed_payjoin_proposal_p2pkh.clone(),
1466+
};
1467+
let monitor = Receiver {
1468+
state: Monitor { psbt_context: psbt_ctx_p2pkh },
1469+
session_context: SHARED_CONTEXT.clone(),
1470+
};
14791471

14801472
let persister = InMemoryTestPersister::default();
1481-
monitor
1482-
.check_payment(
1483-
|_| Ok(None),
1484-
|outpoint| {
1485-
if outpoint == payjoin_tx.input[0].previous_output {
1486-
Ok(true)
1487-
} else {
1488-
Ok(false)
1489-
}
1490-
},
1491-
)
1473+
let res = monitor
1474+
.check_payment(|_| panic!("check_payment should return before this closure is called"))
14921475
.save(&persister)
14931476
.expect("InMemoryTestPersister shouldn't fail");
14941477

1478+
assert!(matches!(res, OptionalTransitionOutcome::Progress(_)));
14951479
assert!(persister.inner.read().expect("Shouldn't be poisoned").is_closed);
1496-
assert_eq!(persister.inner.read().expect("Shouldn't be poisoned").events.len(), 1);
14971480
assert_eq!(
14981481
persister.inner.read().expect("Shouldn't be poisoned").events.last(),
1499-
Some(&SessionEvent::Closed(SessionOutcome::Failure))
1482+
Some(&SessionEvent::Closed(SessionOutcome::BroadcastStatusUnknown))
15001483
);
15011484

1502-
// assert_eq!(
1503-
// err.to_string(),
1504-
// Error::Protocol(ProtocolError::V2(
1505-
// InternalSessionError::FallbackOutpointsSpent(vec![
1506-
// payjoin_tx.input[0].previous_output
1507-
// ],)
1508-
// .into()
1509-
// ))
1510-
// .to_string()
1511-
// );
1512-
15131485
Ok(())
15141486
}
15151487

payjoin/src/core/receive/v2/session.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,8 @@ impl SessionHistory {
119119
}
120120
match self.events.last() {
121121
Some(SessionEvent::Closed(outcome)) => match outcome {
122-
SessionOutcome::Success(_) => SessionStatus::Completed,
122+
SessionOutcome::Success(_) | SessionOutcome::BroadcastStatusUnknown =>
123+
SessionStatus::Completed,
123124
SessionOutcome::Failure | SessionOutcome::Cancel => SessionStatus::Failed,
124125
SessionOutcome::FallbackBroadcasted => SessionStatus::FallbackBroadcasted,
125126
},
@@ -169,6 +170,9 @@ pub enum SessionOutcome {
169170
Cancel,
170171
/// Fallback transaction was broadcasted
171172
FallbackBroadcasted,
173+
/// The broadcast status of the sent Payjoin proposal cannot be tracked because the sender is
174+
/// using non-SegWit addresses which will change the transaction ID of the proposal.
175+
BroadcastStatusUnknown,
172176
}
173177

174178
#[cfg(test)]

payjoin/tests/integration.rs

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -630,19 +630,14 @@ mod integration {
630630
);
631631

632632
// monitor the payment on the receiver side
633-
monitoring_payment.check_payment(
634-
|txid| {
635-
let tx = receiver
636-
.get_raw_transaction(txid)
637-
.expect("transaction should exist")
638-
.transaction()
639-
.expect("transaction should be decodable");
640-
Ok(Some(tx))
641-
},
642-
|_| {
643-
panic!("We should never check outpoints since the payjoin tx has been broadcasted")
644-
},
645-
);
633+
monitoring_payment.check_payment(|txid| {
634+
let tx = receiver
635+
.get_raw_transaction(txid)
636+
.expect("transaction should exist")
637+
.transaction()
638+
.expect("transaction should be decodable");
639+
Ok(Some(tx))
640+
});
646641
Ok(())
647642
}
648643

0 commit comments

Comments
 (0)