Skip to content

Commit a02bfce

Browse files
authored
Skip check_payment in Receiver<Monitor> if the sender is using non-segwit address (#1218)
2 parents e1fd929 + fb368c0 commit a02bfce

6 files changed

Lines changed: 176 additions & 196 deletions

File tree

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

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -79,9 +79,11 @@ impl StatusText for ReceiveSession {
7979
ReceiveSession::Monitor(_) => "Monitoring payjoin proposal",
8080
ReceiveSession::Closed(session_outcome) => match session_outcome {
8181
ReceiverSessionOutcome::Failure => "Session failure",
82-
ReceiverSessionOutcome::Success(_) => "Session success",
82+
ReceiverSessionOutcome::Success(_) => "Session success, Payjoin proposal was broadcasted",
8383
ReceiverSessionOutcome::Cancel => "Session cancelled",
8484
ReceiverSessionOutcome::FallbackBroadcasted => "Fallback broadcasted",
85+
ReceiverSessionOutcome::PayjoinProposalSent =>
86+
"Payjoin proposal sent, skipping monitoring as the sender is spending non-SegWit inputs",
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: 69 additions & 98 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();
@@ -1270,7 +1279,7 @@ impl Receiver<Monitor> {
12701279
tx.input.get(i).expect("sender_input_indexes should return valid indices");
12711280
sender_witnesses.push((input.script_sig.clone(), input.witness.clone()));
12721281
}
1273-
// Payjoin transaction with segwit inputs was detected. Log the signatures and complete the session
1282+
// Payjoin transaction with SegWit inputs was detected. Log the signatures and complete the session.
12741283
return MaybeFatalOrSuccessTransition::success(SessionEvent::Closed(
12751284
SessionOutcome::Success(sender_witnesses),
12761285
));
@@ -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,82 +1434,73 @@ 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

14611441
assert!(matches!(res, OptionalTransitionOutcome::Progress(_)));
14621442
assert!(persister.inner.read().expect("Shouldn't be poisoned").is_closed);
14631443
assert_eq!(persister.inner.read().expect("Shouldn't be poisoned").events.len(), 1);
1464-
// TODO: check for exact events
1444+
assert_eq!(
1445+
persister.inner.read().expect("Shouldn't be poisoned").events.last(),
1446+
Some(&SessionEvent::Closed(SessionOutcome::Success(vec![(
1447+
ScriptBuf::default(),
1448+
Witness::default()
1449+
)])))
1450+
);
14651451

1466-
// fallback was broadcasted, should progress to success
1452+
// Fallback was broadcasted, should progress to success
14671453
let persister = InMemoryTestPersister::default();
14681454
let res = monitor
1469-
.check_payment(
1470-
|txid| {
1471-
// Emulate if one of the fallback outpoints was double spent
1472-
if txid == original_tx.compute_txid() {
1473-
Ok(Some(original_tx.clone()))
1474-
} else {
1475-
Ok(None)
1476-
}
1477-
},
1478-
|_| Ok(false),
1479-
)
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+
})
14801463
.save(&persister)
14811464
.expect("InMemoryTestPersister shouldn't fail");
14821465

14831466
assert!(matches!(res, OptionalTransitionOutcome::Progress(_)));
14841467
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);
14851469
assert_eq!(
14861470
persister.inner.read().expect("Shouldn't be poisoned").events.last(),
14871471
Some(&SessionEvent::Closed(SessionOutcome::FallbackBroadcasted))
14881472
);
14891473

1490-
let persister = InMemoryTestPersister::default();
1491-
let res = monitor
1492-
.check_payment(|_| Ok(None), |_| Ok(true))
1493-
.save(&persister)
1494-
.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");
14951480

1496-
assert!(matches!(res, OptionalTransitionOutcome::Progress(_)));
1497-
assert!(persister.inner.read().expect("Shouldn't be poisoned").is_closed);
1498-
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+
};
14991489

15001490
let persister = InMemoryTestPersister::default();
1501-
monitor
1502-
.check_payment(
1503-
|_| Ok(None),
1504-
|outpoint| {
1505-
if outpoint == payjoin_tx.input[0].previous_output {
1506-
Ok(true)
1507-
} else {
1508-
Ok(false)
1509-
}
1510-
},
1511-
)
1491+
let res = monitor
1492+
.check_payment(|_| panic!("check_payment should return before this closure is called"))
15121493
.save(&persister)
15131494
.expect("InMemoryTestPersister shouldn't fail");
15141495

1496+
assert!(matches!(res, OptionalTransitionOutcome::Progress(_)));
15151497
assert!(persister.inner.read().expect("Shouldn't be poisoned").is_closed);
15161498
assert_eq!(persister.inner.read().expect("Shouldn't be poisoned").events.len(), 1);
15171499
assert_eq!(
15181500
persister.inner.read().expect("Shouldn't be poisoned").events.last(),
1519-
Some(&SessionEvent::Closed(SessionOutcome::Failure))
1501+
Some(&SessionEvent::Closed(SessionOutcome::PayjoinProposalSent))
15201502
);
15211503

1522-
// assert_eq!(
1523-
// err.to_string(),
1524-
// Error::Protocol(ProtocolError::V2(
1525-
// InternalSessionError::FallbackOutpointsSpent(vec![
1526-
// payjoin_tx.input[0].previous_output
1527-
// ],)
1528-
// .into()
1529-
// ))
1530-
// .to_string()
1531-
// );
1532-
15331504
Ok(())
15341505
}
15351506

‎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)