Skip to content

Commit 587af2d

Browse files
committed
Create a duplicate proposal check in ns1r
When building a multiparty proposal we did not have a catch to make sure that the proposals were all unique and as such a multiparty could be built by just adding the same proposal over and over. This creates a new error IdenticalProposals that checks to make sure the contexts are not matching when adding to a multiparty.
1 parent a556161 commit 587af2d

File tree

4 files changed

+182
-19
lines changed

4 files changed

+182
-19
lines changed

payjoin-test-utils/src/lib.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,3 +299,7 @@ pub static PARSED_PAYJOIN_PROPOSAL: Lazy<Psbt> =
299299
pub static PARSED_PAYJOIN_PROPOSAL_WITH_SENDER_INFO: Lazy<Psbt> = Lazy::new(|| {
300300
Psbt::from_str(PAYJOIN_PROPOSAL_WITH_SENDER_INFO).expect("known psbt should parse")
301301
});
302+
303+
pub const MULTIPARTY_ORIGINAL_PSBT_ONE: &str = "cHNidP8BAMMCAAAAA9cTcqYjSnJdteUn3Re12vFuVb5xPypYheHze74WBW8EAAAAAAD+////6JHS6JgYdav7tr+Q5bWk95C2aA4AudqZnjeRF0hx33gBAAAAAP7///+d/1mRjJ6snVe9o0EfMM8bIExdlwfaig1j/JnUupqIWwAAAAAA/v///wJO4wtUAgAAABYAFGAEc6cWf7z5a97Xxulg+S7JZyg/kvEFKgEAAAAWABQexWFsGEdXxYNTJLJ1qBCO0nXELwAAAAAAAQCaAgAAAAKa/zFrWjicRDNVCehGj/2jdXGKuZLMzLNUo+uj7PBW8wAAAAAA/v///4HkRlmU9FA3sHckkCQMjGrabNpgAw37XaqUItNFjR5rAAAAAAD+////AgDyBSoBAAAAFgAUGPI/E9xCHmaL6DIsjkaD8LX2mpLg6QUqAQAAABYAFH6MyygysrAmk/nunEFuGGDLf244aAAAAAEBHwDyBSoBAAAAFgAUGPI/E9xCHmaL6DIsjkaD8LX2mpIBCGsCRzBEAiBLJUhV7tja6FdELXDcB6Q3Gd+BMBpkjdHpj3DLnbL6AAIgJmFl3kpWBzkO8yDDl73BMMalSlAOQvfY+hqFRos5DhQBIQLfeq7CCftNEUHcRLZniJOqcgKZEqPc1A40BnEiZHj3FQABAJoCAAAAAp3/WZGMnqydV72jQR8wzxsgTF2XB9qKDWP8mdS6mohbAQAAAAD+////1xNypiNKcl215SfdF7Xa8W5VvnE/KliF4fN7vhYFbwQBAAAAAP7///8CoNkFKgEAAAAWABQchAe9uxzqT1EjLx4jgx9u1Mn6QADyBSoBAAAAFgAUye8yXWX0MvouXYhAFb06xX5kADpoAAAAAQEfAPIFKgEAAAAWABTJ7zJdZfQy+i5diEAVvTrFfmQAOgEIawJHMEQCIF7ihY/YtUPUTOaEdJbg0/HiwKunK398BI67/LknPGqMAiBHBXmL6gTP8PxEGeWswk6T0tCI2Gvwq1zh+wd7h8QCWAEhApM0w2WFlw0eg64Zp3PeyRmxl/7WGHUED8Ul/aX1FiTBAAAAAA==";
304+
305+
pub const MULTIPARTY_ORIGINAL_PSBT_TWO: &str = "cHNidP8BAMMCAAAAA9cTcqYjSnJdteUn3Re12vFuVb5xPypYheHze74WBW8EAAAAAAD+////6JHS6JgYdav7tr+Q5bWk95C2aA4AudqZnjeRF0hx33gBAAAAAP7///+d/1mRjJ6snVe9o0EfMM8bIExdlwfaig1j/JnUupqIWwAAAAAA/v///wJO4wtUAgAAABYAFGAEc6cWf7z5a97Xxulg+S7JZyg/kvEFKgEAAAAWABQexWFsGEdXxYNTJLJ1qBCO0nXELwAAAAAAAAEAmgIAAAACnf9ZkYyerJ1XvaNBHzDPGyBMXZcH2ooNY/yZ1LqaiFsBAAAAAP7////XE3KmI0pyXbXlJ90XtdrxblW+cT8qWIXh83u+FgVvBAEAAAAA/v///wKg2QUqAQAAABYAFByEB727HOpPUSMvHiODH27UyfpAAPIFKgEAAAAWABTJ7zJdZfQy+i5diEAVvTrFfmQAOmgAAAABAR8A8gUqAQAAABYAFMnvMl1l9DL6Ll2IQBW9OsV+ZAA6AQhrAkcwRAIgXuKFj9i1Q9RM5oR0luDT8eLAq6crf3wEjrv8uSc8aowCIEcFeYvqBM/w/EQZ5azCTpPS0IjYa/CrXOH7B3uHxAJYASECkzTDZYWXDR6Drhmnc97JGbGX/tYYdQQPxSX9pfUWJMEAAQCaAgAAAAIKOB8lY4eoEupDnxviz0/nAuR2biNFKbdkvckiW5ioPAAAAAAA/v///w0g/mj67592vy29xhnZMGeVtEXN1jD4lU/SMZM8oStqAAAAAAD+////AgDyBSoBAAAAFgAUC3r4YzVSpsWp3knMxbgWIx2R36/g6QUqAQAAABYAFMGlw3hwcx1b+KQGWIfOUxzwrwWkaAAAAAEBHwDyBSoBAAAAFgAUC3r4YzVSpsWp3knMxbgWIx2R368BCGsCRzBEAiA//JH+jonzbzqnKI0Uti16iJcdsXI+6Zu0IAZKlOq6AwIgP0XawCCH73uXKilFqSXQQQrBvmi/Sx44D/A+/MQ/mJYBIQIekOyEpJKpFQd7eHuY6Vt4Qlf0+00Wp529I23hl/EpcQAAAA==";

payjoin/src/receive/multiparty/error.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
use core::fmt;
22
use std::error;
33

4+
use crate::uri::ShortId;
5+
46
#[derive(Debug)]
57
pub struct MultipartyError(InternalMultipartyError);
68

79
#[derive(Debug)]
810
pub(crate) enum InternalMultipartyError {
911
/// Not enough proposals
1012
NotEnoughProposals,
13+
/// Duplicate proposals
14+
IdenticalProposals(IdenticalProposalError),
1115
/// Proposal version not supported
1216
ProposalVersionNotSupported(usize),
1317
/// Optimistic merge not supported
@@ -20,6 +24,27 @@ pub(crate) enum InternalMultipartyError {
2024
FailedToCombinePsbts(bitcoin::psbt::Error),
2125
}
2226

27+
#[derive(Debug)]
28+
pub enum IdenticalProposalError {
29+
IdenticalPsbts(Box<bitcoin::Psbt>, Box<bitcoin::Psbt>),
30+
IdenticalContexts(Box<ShortId>, Box<ShortId>),
31+
}
32+
33+
impl std::fmt::Display for IdenticalProposalError {
34+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35+
match self {
36+
IdenticalProposalError::IdenticalPsbts(current_psbt, incoming_psbt) => write!(
37+
f,
38+
"Two sender psbts are identical\n left psbt: {current_psbt}\n right psbt: {incoming_psbt}"
39+
),
40+
IdenticalProposalError::IdenticalContexts(current_context, incoming_context) => write!(
41+
f,
42+
"Two sender contexts are identical\n left id: {current_context}\n right id: {incoming_context}"
43+
),
44+
}
45+
}
46+
}
47+
2348
impl From<InternalMultipartyError> for MultipartyError {
2449
fn from(e: InternalMultipartyError) -> Self { MultipartyError(e) }
2550
}
@@ -28,6 +53,8 @@ impl fmt::Display for MultipartyError {
2853
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
2954
match &self.0 {
3055
InternalMultipartyError::NotEnoughProposals => write!(f, "Not enough proposals"),
56+
InternalMultipartyError::IdenticalProposals(e) =>
57+
write!(f, "More than one identical participant: {e}"),
3158
InternalMultipartyError::ProposalVersionNotSupported(v) =>
3259
write!(f, "Proposal version not supported: {v}"),
3360
InternalMultipartyError::OptimisticMergeNotSupported =>
@@ -46,6 +73,7 @@ impl error::Error for MultipartyError {
4673
fn source(&self) -> Option<&(dyn error::Error + 'static)> {
4774
match &self.0 {
4875
InternalMultipartyError::NotEnoughProposals => None,
76+
InternalMultipartyError::IdenticalProposals(_) => None,
4977
InternalMultipartyError::ProposalVersionNotSupported(_) => None,
5078
InternalMultipartyError::OptimisticMergeNotSupported => None,
5179
InternalMultipartyError::BitcoinExtractTxError(e) => Some(e),

payjoin/src/receive/multiparty/mod.rs

Lines changed: 136 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use bitcoin::{FeeRate, Psbt};
2+
use error::IdenticalProposalError;
23

34
use super::error::InputContributionError;
45
use super::{v1, v2, Error, ImplementationError, InputPair};
@@ -36,6 +37,30 @@ impl UncheckedProposalBuilder {
3637
if !params.optimistic_merge {
3738
return Err(InternalMultipartyError::OptimisticMergeNotSupported.into());
3839
}
40+
41+
if let Some(duplicate_context) =
42+
self.proposals.iter().find(|c| c.context == proposal.context)
43+
{
44+
return Err(InternalMultipartyError::IdenticalProposals(
45+
IdenticalProposalError::IdenticalContexts(
46+
Box::new(duplicate_context.id()),
47+
Box::new(proposal.id()),
48+
),
49+
)
50+
.into());
51+
};
52+
53+
if let Some(duplicate_psbt) =
54+
self.proposals.iter().find(|psbt| psbt.v1.psbt == proposal.v1.psbt)
55+
{
56+
return Err(InternalMultipartyError::IdenticalProposals(
57+
IdenticalProposalError::IdenticalPsbts(
58+
Box::new(duplicate_psbt.v1.psbt.clone()),
59+
Box::new(proposal.v1.psbt.clone()),
60+
),
61+
)
62+
.into());
63+
}
3964
Ok(())
4065
}
4166

@@ -218,6 +243,29 @@ impl FinalizedProposal {
218243
InternalMultipartyError::ProposalVersionNotSupported(proposal.v1.params.v).into()
219244
);
220245
}
246+
if let Some(duplicate_context) =
247+
self.v2_proposals.iter().find(|c| c.context == proposal.context)
248+
{
249+
return Err(InternalMultipartyError::IdenticalProposals(
250+
IdenticalProposalError::IdenticalContexts(
251+
Box::new(duplicate_context.id()),
252+
Box::new(proposal.id()),
253+
),
254+
)
255+
.into());
256+
};
257+
258+
if let Some(duplicate_psbt) =
259+
self.v2_proposals.iter().find(|psbt| psbt.v1.psbt == proposal.v1.psbt)
260+
{
261+
return Err(InternalMultipartyError::IdenticalProposals(
262+
IdenticalProposalError::IdenticalPsbts(
263+
Box::new(duplicate_psbt.v1.psbt.clone()),
264+
Box::new(proposal.v1.psbt.clone()),
265+
),
266+
)
267+
.into());
268+
}
221269
Ok(())
222270
}
223271

@@ -253,51 +301,120 @@ impl FinalizedProposal {
253301
mod test {
254302

255303
use std::any::{Any, TypeId};
256-
257-
use payjoin_test_utils::{BoxError, PARSED_ORIGINAL_PSBT};
258-
259-
use super::{v1, v2, FinalizedProposal, UncheckedProposalBuilder, SUPPORTED_VERSIONS};
304+
use std::str::FromStr;
305+
306+
use bitcoin::Psbt;
307+
use payjoin_test_utils::{
308+
BoxError, MULTIPARTY_ORIGINAL_PSBT_ONE, MULTIPARTY_ORIGINAL_PSBT_TWO,
309+
};
310+
311+
use super::error::IdenticalProposalError;
312+
use super::{
313+
v1, v2, FinalizedProposal, InternalMultipartyError, MultipartyError,
314+
UncheckedProposalBuilder, SUPPORTED_VERSIONS,
315+
};
260316
use crate::receive::optional_parameters::Params;
261-
use crate::receive::v2::test::SHARED_CONTEXT;
317+
use crate::receive::v2::test::{SHARED_CONTEXT, SHARED_CONTEXT_TWO};
262318

263-
fn multiparty_proposal_from_test_vector() -> v1::UncheckedProposal {
319+
fn multiparty_proposals() -> Vec<v1::UncheckedProposal> {
264320
let pairs = url::form_urlencoded::parse("v=2&optimisticmerge=true".as_bytes());
265321
let params = Params::from_query_pairs(pairs, SUPPORTED_VERSIONS)
266322
.expect("Could not parse from query pairs");
267-
v1::UncheckedProposal { psbt: PARSED_ORIGINAL_PSBT.clone(), params }
323+
324+
[MULTIPARTY_ORIGINAL_PSBT_ONE, MULTIPARTY_ORIGINAL_PSBT_TWO]
325+
.iter()
326+
.map(|psbt_str| v1::UncheckedProposal {
327+
psbt: Psbt::from_str(psbt_str).expect("known psbt should parse"),
328+
params: params.clone(),
329+
})
330+
.collect()
268331
}
269332

270333
#[test]
271-
fn test_build_multiparty() -> Result<(), BoxError> {
334+
fn test_single_context_multiparty() -> Result<(), BoxError> {
272335
let proposal_one = v2::UncheckedProposal {
273-
v1: multiparty_proposal_from_test_vector(),
336+
v1: multiparty_proposals()[0].clone(),
337+
context: SHARED_CONTEXT.clone(),
338+
};
339+
let mut multiparty = UncheckedProposalBuilder::new();
340+
multiparty.add(proposal_one)?;
341+
match multiparty.build() {
342+
Ok(_) => panic!("multiparty has two identical participants and should error"),
343+
Err(e) => assert_eq!(
344+
e.to_string(),
345+
MultipartyError::from(InternalMultipartyError::NotEnoughProposals).to_string()
346+
),
347+
}
348+
Ok(())
349+
}
350+
351+
#[test]
352+
fn test_duplicate_context_multiparty() -> Result<(), BoxError> {
353+
let proposal_one = v2::UncheckedProposal {
354+
v1: multiparty_proposals()[0].clone(),
274355
context: SHARED_CONTEXT.clone(),
275356
};
276357
let proposal_two = v2::UncheckedProposal {
277-
v1: multiparty_proposal_from_test_vector(),
358+
v1: multiparty_proposals()[1].clone(),
278359
context: SHARED_CONTEXT.clone(),
279360
};
280-
let mut multiparty = UncheckedProposalBuilder::new();
281-
multiparty.add(proposal_one)?;
282-
multiparty.add(proposal_two)?;
283-
let unchecked_proposal = multiparty.build();
284-
assert!(unchecked_proposal?.contexts.len() == 2);
361+
let mut multiparty = UncheckedProposalBuilder::new().add(proposal_one.clone())?;
362+
match multiparty.add(proposal_two.clone()) {
363+
Ok(_) => panic!("multiparty has two identical contexts and should error"),
364+
Err(e) => assert_eq!(
365+
e.to_string(),
366+
MultipartyError::from(InternalMultipartyError::IdenticalProposals(
367+
IdenticalProposalError::IdenticalContexts(
368+
Box::new(proposal_one.id()),
369+
Box::new(proposal_two.id())
370+
)
371+
))
372+
.to_string()
373+
),
374+
}
375+
Ok(())
376+
}
377+
378+
#[test]
379+
fn test_duplicate_psbt_multiparty() -> Result<(), BoxError> {
380+
let proposal_one = v2::UncheckedProposal {
381+
v1: multiparty_proposals()[0].clone(),
382+
context: SHARED_CONTEXT.clone(),
383+
};
384+
let proposal_two = v2::UncheckedProposal {
385+
v1: multiparty_proposals()[0].clone(),
386+
context: SHARED_CONTEXT_TWO.clone(),
387+
};
388+
let mut multiparty = UncheckedProposalBuilder::new().add(proposal_one.clone())?;
389+
match multiparty.add(proposal_two.clone()) {
390+
Ok(_) => panic!("multiparty has two identical psbts and should error"),
391+
Err(e) => assert_eq!(
392+
e.to_string(),
393+
MultipartyError::from(InternalMultipartyError::IdenticalProposals(
394+
IdenticalProposalError::IdenticalPsbts(
395+
Box::new(proposal_one.v1.psbt),
396+
Box::new(proposal_two.v1.psbt)
397+
)
398+
))
399+
.to_string()
400+
),
401+
}
285402
Ok(())
286403
}
287404

288405
#[test]
289406
fn finalize_multiparty() -> Result<(), BoxError> {
290407
use crate::psbt::PsbtExt;
291408
let proposal_one = v2::UncheckedProposal {
292-
v1: multiparty_proposal_from_test_vector(),
409+
v1: multiparty_proposals()[0].clone(),
293410
context: SHARED_CONTEXT.clone(),
294411
};
295412
let proposal_two = v2::UncheckedProposal {
296-
v1: multiparty_proposal_from_test_vector(),
297-
context: SHARED_CONTEXT.clone(),
413+
v1: multiparty_proposals()[1].clone(),
414+
context: SHARED_CONTEXT_TWO.clone(),
298415
};
299416
let mut finalized_multiparty = FinalizedProposal::new();
300-
finalized_multiparty.add(proposal_one.clone())?;
417+
finalized_multiparty.add(proposal_one)?;
301418
assert_eq!(finalized_multiparty.v2()[0].type_id(), TypeId::of::<v2::UncheckedProposal>());
302419

303420
finalized_multiparty.add(proposal_two)?;

payjoin/src/receive/v2/mod.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -660,6 +660,20 @@ pub mod test {
660660
e: None,
661661
});
662662

663+
pub(crate) static SHARED_CONTEXT_TWO: Lazy<SessionContext> = Lazy::new(|| SessionContext {
664+
address: Address::from_str("tb1qv7scm7gxs32qg3lnm9kf267kllc63yvdxyh72e")
665+
.expect("valid address")
666+
.assume_checked(),
667+
directory: EXAMPLE_URL.clone(),
668+
subdirectory: None,
669+
ohttp_keys: OhttpKeys(
670+
ohttp::KeyConfig::new(KEY_ID, KEM, Vec::from(SYMMETRIC)).expect("valid key config"),
671+
),
672+
expiry: SystemTime::now() + Duration::from_secs(60),
673+
s: HpkeKeyPair::gen_keypair(),
674+
e: None,
675+
});
676+
663677
#[test]
664678
fn extract_err_req() -> Result<(), BoxError> {
665679
let mut proposal = UncheckedProposal {

0 commit comments

Comments
 (0)