Skip to content

Commit d3b5192

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 d4c1053 commit d3b5192

File tree

6 files changed

+166
-192
lines changed

6 files changed

+166
-192
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::PayjoinProposalSent =>
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: 60 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -1228,32 +1228,41 @@ pub struct Monitor {
12281228
/// Call [`Receiver<Monitor>::check_payment`] to confirm the broadcast and conclude the Payjoin
12291229
/// session.
12301230
impl Receiver<Monitor> {
1231-
/// Checks if the Payjoin proposal, the fallback transaction, or a double-spend attempt
1232-
/// has been broadcasted by the sender. If the sender broadcasted either the Payjoin proposal
1233-
/// or the fallback transaction, concludes the Payjoin session with a success. If there was a
1234-
/// double-spend attempt, concludes with a failure.
1231+
/// Checks if the Payjoin proposal or the fallback transaction, has been broadcasted by the sender.
1232+
/// If the sender broadcasted either the Payjoin proposal or the fallback transaction, concludes
1233+
/// the Payjoin session with a success.
12351234
///
1236-
/// After the receiver has finalized the Payjoin proposal and sent it to the sender for the
1237-
/// final signature and broadcast, what the sender does changes how the receiver should track
1238-
/// the network and confirm that Payjoin session has concluded:
1239-
///
1240-
/// 1. The sender may contribute segwit inputs, which would keep the transaction ID the same as
1241-
/// what it was when the receiver sent the Payjoin proposal. In this case, the
1242-
/// `transaction_exists` function will be used to confirm the broadcast.
1243-
/// 2. The sender may contribute non-segwit inputs, which would change the
1244-
/// transaction ID. In this case, `outpoint_spent` will be used to confirm that the UTXOs
1245-
/// the receiver contributed with have been spent. This function will fail if UTXOs have
1246-
/// been spent but not in the Payjoin proposal, signalling a double-spend.
1247-
/// 3. The sender might not broadcast the Payjoin transaction and instead broadcast the original
1248-
/// proposal which paid to the receiver but did not have any receiver contributions.
1235+
/// If the receiver input address type in the fallback transaction is non-SegWit, then this
1236+
/// function will directly conclude the Payjoin session with a Success without running the
1237+
/// provided `transaction_exists` closure. `transaction_exists` uses the transaction ID to
1238+
/// search for the transaction in the network. Since a non-SegWit input signature is going to
1239+
/// change the TXID of the Payjoin proposal, it cannot be monitored.
12491240
pub fn check_payment(
12501241
&self,
12511242
transaction_exists: impl Fn(Txid) -> Result<Option<bitcoin::Transaction>, ImplementationError>,
1252-
outpoint_spent: impl Fn(OutPoint) -> Result<bool, ImplementationError>,
12531243
) -> MaybeFatalOrSuccessTransition<SessionEvent, Self, Error> {
1244+
let fallback_tx = self
1245+
.state
1246+
.psbt_context
1247+
.original_psbt
1248+
.clone()
1249+
.extract_tx_fee_rate_limit()
1250+
.expect("fallback transaction should be in the receiver context");
1251+
1252+
// If the fallback transaction included any non-SegWit inputs, then the transaction ID of
1253+
// the Payjoin proposal is going to change when the sender signs their non-SegWit address
1254+
// one more time. The receiver cannot monitor the broadcast, and should conclude the session.
1255+
if fallback_tx.input.iter().any(|txin| txin.witness.is_empty()) {
1256+
return MaybeFatalOrSuccessTransition::success(SessionEvent::Closed(
1257+
SessionOutcome::PayjoinProposalSent,
1258+
));
1259+
}
1260+
12541261
let payjoin_proposal = &self.state.psbt_context.payjoin_psbt;
12551262
let payjoin_txid = payjoin_proposal.unsigned_tx.compute_txid();
1256-
// If we have a payjoin transaction with segwit inputs, we can check for the txid
1263+
// If the sender is spending SegWit-only inputs, then the transaction ID of the Payjoin proposal
1264+
// is not going to change when the sender signs it. So we can use the TXID to determine if
1265+
// the Payjoin proposal has been broadcasted.
12571266
match transaction_exists(payjoin_txid) {
12581267
Ok(Some(tx)) => {
12591268
let tx_id = tx.compute_txid();
@@ -1279,14 +1288,8 @@ impl Receiver<Monitor> {
12791288
Err(e) => return MaybeFatalOrSuccessTransition::transient(Error::Implementation(e)),
12801289
}
12811290

1282-
// Check for fallback being broadcasted
1283-
let fallback_tx = self
1284-
.state
1285-
.psbt_context
1286-
.original_psbt
1287-
.clone()
1288-
.extract_tx_fee_rate_limit()
1289-
.expect("Checked in earlier typestates");
1291+
// If the Payjoin proposal was not found, check the fallback transaction, at it is
1292+
// the second of two transactions whose IDs the receiver is aware of.
12901293
match transaction_exists(fallback_tx.compute_txid()) {
12911294
Ok(Some(_)) =>
12921295
return MaybeFatalOrSuccessTransition::success(SessionEvent::Closed(
@@ -1296,29 +1299,6 @@ impl Receiver<Monitor> {
12961299
Err(e) => return MaybeFatalOrSuccessTransition::transient(Error::Implementation(e)),
12971300
}
12981301

1299-
let mut outpoints_spend = 0;
1300-
for ot in payjoin_proposal.unsigned_tx.input.iter() {
1301-
match outpoint_spent(ot.previous_output) {
1302-
Ok(false) => {}
1303-
Ok(true) => outpoints_spend += 1,
1304-
Err(e) =>
1305-
return MaybeFatalOrSuccessTransition::transient(Error::Implementation(e)),
1306-
}
1307-
}
1308-
1309-
if outpoints_spend == payjoin_proposal.unsigned_tx.input.len() {
1310-
// All the payjoin proposal outpoints were spent. This means our payjoin proposal has non-segwit inputs and is broadcasted.
1311-
return MaybeFatalOrSuccessTransition::success(SessionEvent::Closed(
1312-
// TODO: there seems to be not great way to get the tx of the tx that spent these outpoints.
1313-
SessionOutcome::Success(vec![]),
1314-
));
1315-
} else if outpoints_spend > 0 {
1316-
// Some outpoints were spent but not in the payjoin proposal. This is a double spend.
1317-
return MaybeFatalOrSuccessTransition::success(SessionEvent::Closed(
1318-
SessionOutcome::Failure,
1319-
));
1320-
}
1321-
13221302
MaybeFatalOrSuccessTransition::no_results(self.clone())
13231303
}
13241304
}
@@ -1359,7 +1339,7 @@ pub(crate) fn pj_uri<'a>(
13591339
pub mod test {
13601340
use std::str::FromStr;
13611341

1362-
use bitcoin::FeeRate;
1342+
use bitcoin::{FeeRate, ScriptBuf, Witness};
13631343
use once_cell::sync::Lazy;
13641344
use payjoin_test_utils::{
13651345
BoxError, EXAMPLE_URL, KEM, KEY_ID, ORIGINAL_PSBT, PARSED_ORIGINAL_PSBT,
@@ -1444,7 +1424,7 @@ pub mod test {
14441424
// Nothing was spent, should be in the same state
14451425
let persister = InMemoryTestPersister::default();
14461426
let res = monitor
1447-
.check_payment(|_| Ok(None), |_| Ok(false))
1427+
.check_payment(|_| Ok(None))
14481428
.save(&persister)
14491429
.expect("InMemoryTestPersister shouldn't fail");
14501430
assert!(matches!(res, OptionalTransitionOutcome::Stasis(_)));
@@ -1454,7 +1434,7 @@ pub mod test {
14541434
// Payjoin was broadcasted, should progress to success
14551435
let persister = InMemoryTestPersister::default();
14561436
let res = monitor
1457-
.check_payment(|_| Ok(Some(payjoin_tx.clone())), |_| Ok(false))
1437+
.check_payment(|_| Ok(Some(payjoin_tx.clone())))
14581438
.save(&persister)
14591439
.expect("InMemoryTestPersister shouldn't fail");
14601440

@@ -1472,70 +1452,55 @@ pub mod test {
14721452
// Fallback was broadcasted, should progress to success
14731453
let persister = InMemoryTestPersister::default();
14741454
let res = monitor
1475-
.check_payment(
1476-
|txid| {
1477-
// Emulate if one of the fallback outpoints was double spent
1478-
if txid == original_tx.compute_txid() {
1479-
Ok(Some(original_tx.clone()))
1480-
} else {
1481-
Ok(None)
1482-
}
1483-
},
1484-
|_| Ok(false),
1485-
)
1455+
.check_payment(|txid| {
1456+
// Emulate if one of the fallback outpoints was double spent
1457+
if txid == original_tx.compute_txid() {
1458+
Ok(Some(original_tx.clone()))
1459+
} else {
1460+
Ok(None)
1461+
}
1462+
})
14861463
.save(&persister)
14871464
.expect("InMemoryTestPersister shouldn't fail");
14881465

14891466
assert!(matches!(res, OptionalTransitionOutcome::Progress(_)));
14901467
assert!(persister.inner.read().expect("Shouldn't be poisoned").is_closed);
1468+
assert_eq!(persister.inner.read().expect("Shouldn't be poisoned").events.len(), 1);
14911469
assert_eq!(
14921470
persister.inner.read().expect("Shouldn't be poisoned").events.last(),
14931471
Some(&SessionEvent::Closed(SessionOutcome::FallbackBroadcasted))
14941472
);
14951473

1496-
let persister = InMemoryTestPersister::default();
1497-
let res = monitor
1498-
.check_payment(|_| Ok(None), |_| Ok(true))
1499-
.save(&persister)
1500-
.expect("InMemoryTestPersister shouldn't fail");
1474+
// Fallback transaction is non-SegWit address type, should end the session without checking
1475+
// the network for broadcasts.
1476+
// Not using the test-utils vectors here as they are SegWit.
1477+
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");
1478+
let parsed_payjoin_proposal_p2pkh: Psbt =
1479+
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");
15011480

1502-
assert!(matches!(res, OptionalTransitionOutcome::Progress(_)));
1503-
assert!(persister.inner.read().expect("Shouldn't be poisoned").is_closed);
1504-
assert_eq!(persister.inner.read().expect("Shouldn't be poisoned").events.len(), 1);
1481+
let psbt_ctx_p2pkh = PsbtContext {
1482+
original_psbt: parsed_original_psbt_p2pkh.clone(),
1483+
payjoin_psbt: parsed_payjoin_proposal_p2pkh.clone(),
1484+
};
1485+
let monitor = Receiver {
1486+
state: Monitor { psbt_context: psbt_ctx_p2pkh },
1487+
session_context: SHARED_CONTEXT.clone(),
1488+
};
15051489

15061490
let persister = InMemoryTestPersister::default();
1507-
monitor
1508-
.check_payment(
1509-
|_| Ok(None),
1510-
|outpoint| {
1511-
if outpoint == payjoin_tx.input[0].previous_output {
1512-
Ok(true)
1513-
} else {
1514-
Ok(false)
1515-
}
1516-
},
1517-
)
1491+
let res = monitor
1492+
.check_payment(|_| panic!("check_payment should return before this closure is called"))
15181493
.save(&persister)
15191494
.expect("InMemoryTestPersister shouldn't fail");
15201495

1496+
assert!(matches!(res, OptionalTransitionOutcome::Progress(_)));
15211497
assert!(persister.inner.read().expect("Shouldn't be poisoned").is_closed);
15221498
assert_eq!(persister.inner.read().expect("Shouldn't be poisoned").events.len(), 1);
15231499
assert_eq!(
15241500
persister.inner.read().expect("Shouldn't be poisoned").events.last(),
1525-
Some(&SessionEvent::Closed(SessionOutcome::Failure))
1501+
Some(&SessionEvent::Closed(SessionOutcome::PayjoinProposalSent))
15261502
);
15271503

1528-
// assert_eq!(
1529-
// err.to_string(),
1530-
// Error::Protocol(ProtocolError::V2(
1531-
// InternalSessionError::FallbackOutpointsSpent(vec![
1532-
// payjoin_tx.input[0].previous_output
1533-
// ],)
1534-
// .into()
1535-
// ))
1536-
// .to_string()
1537-
// );
1538-
15391504
Ok(())
15401505
}
15411506

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

Lines changed: 6 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::PayjoinProposalSent =>
123+
SessionStatus::Completed,
123124
SessionOutcome::Failure | SessionOutcome::Cancel => SessionStatus::Failed,
124125
SessionOutcome::FallbackBroadcasted => SessionStatus::FallbackBroadcasted,
125126
},
@@ -169,6 +170,10 @@ pub enum SessionOutcome {
169170
Cancel,
170171
/// Fallback transaction was broadcasted
171172
FallbackBroadcasted,
173+
/// Payjoin proposal was sent, but its broadcast status cannot be tracked because
174+
/// the sender is using non-SegWit inputs which will change the transaction ID
175+
/// of the proposal
176+
PayjoinProposalSent,
172177
}
173178

174179
#[cfg(test)]

0 commit comments

Comments
 (0)