Repository: payjoin/rust-payjoin
Pull request #642: Duplicate proposals catch in ns1r
Author: login: benalleng
Created: 2025-04-10T17:06:14Z | Merged: 2025-04-28T16:57:46Z

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.

Closes #640

Linked issue #640: NS1R proposal builder should not accept duplicate proposal
> This is working as expected but I do wonder if contexts here should be len == 1. Since both the hpke context and the proposal is the same it would make sense to me that  `UncheckedProposalBuilder.add()` would disallow the caller to add duplicate sessions. I still think the ns1r session would succeed bc only one PSBT ends up on the directory for this hpke session but it still seems wrong and error prone. This is outside the scope of this PR.

_Originally posted by @0xBEEFCAF3 in https://github.com/payjoin/rust-payjoin/pull/632#discussion_r2037346020_
            
Linked issue comments: user: login: arminsabouri
created_at: 2025-04-10T14:24:20Z
body: Two cases:
1. Where the session is duplicated. 
2. Different session same PSBT
author_association: COLLABORATOR

Diff:
diff --git a/payjoin-test-utils/src/lib.rs b/payjoin-test-utils/src/lib.rs
index b337be34f..65900321a 100644
--- a/payjoin-test-utils/src/lib.rs
+++ b/payjoin-test-utils/src/lib.rs
@@ -299,3 +299,7 @@ pub static PARSED_PAYJOIN_PROPOSAL: Lazy<Psbt> =
 pub static PARSED_PAYJOIN_PROPOSAL_WITH_SENDER_INFO: Lazy<Psbt> = Lazy::new(|| {
     Psbt::from_str(PAYJOIN_PROPOSAL_WITH_SENDER_INFO).expect("known psbt should parse")
 });
+
+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==";
+
+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==";
diff --git a/payjoin/src/receive/multiparty/error.rs b/payjoin/src/receive/multiparty/error.rs
index dbb46b6e8..9506b7021 100644
--- a/payjoin/src/receive/multiparty/error.rs
+++ b/payjoin/src/receive/multiparty/error.rs
@@ -1,6 +1,8 @@
 use core::fmt;
 use std::error;
 
+use crate::uri::ShortId;
+
 #[derive(Debug)]
 pub struct MultipartyError(InternalMultipartyError);
 
@@ -8,6 +10,8 @@ pub struct MultipartyError(InternalMultipartyError);
 pub(crate) enum InternalMultipartyError {
     /// Not enough proposals
     NotEnoughProposals,
+    /// Duplicate proposals
+    IdenticalProposals(IdenticalProposalError),
     /// Proposal version not supported
     ProposalVersionNotSupported(usize),
     /// Optimistic merge not supported
@@ -20,6 +24,27 @@ pub(crate) enum InternalMultipartyError {
     FailedToCombinePsbts(bitcoin::psbt::Error),
 }
 
+#[derive(Debug)]
+pub enum IdenticalProposalError {
+    IdenticalPsbts(Box<bitcoin::Psbt>, Box<bitcoin::Psbt>),
+    IdenticalContexts(Box<ShortId>, Box<ShortId>),
+}
+
+impl std::fmt::Display for IdenticalProposalError {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            IdenticalProposalError::IdenticalPsbts(current_psbt, incoming_psbt) => write!(
+                f,
+                "Two sender psbts are identical\n left psbt: {current_psbt}\n right psbt: {incoming_psbt}"
+            ),
+            IdenticalProposalError::IdenticalContexts(current_context, incoming_context) => write!(
+                f,
+                "Two sender contexts are identical\n left id: {current_context}\n right id: {incoming_context}"
+            ),
+        }
+    }
+}
+
 impl From<InternalMultipartyError> for MultipartyError {
     fn from(e: InternalMultipartyError) -> Self { MultipartyError(e) }
 }
@@ -28,6 +53,8 @@ impl fmt::Display for MultipartyError {
     fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
         match &self.0 {
             InternalMultipartyError::NotEnoughProposals => write!(f, "Not enough proposals"),
+            InternalMultipartyError::IdenticalProposals(e) =>
+                write!(f, "More than one identical participant: {e}"),
             InternalMultipartyError::ProposalVersionNotSupported(v) =>
                 write!(f, "Proposal version not supported: {v}"),
             InternalMultipartyError::OptimisticMergeNotSupported =>
@@ -46,6 +73,7 @@ impl error::Error for MultipartyError {
     fn source(&self) -> Option<&(dyn error::Error + 'static)> {
         match &self.0 {
             InternalMultipartyError::NotEnoughProposals => None,
+            InternalMultipartyError::IdenticalProposals(_) => None,
             InternalMultipartyError::ProposalVersionNotSupported(_) => None,
             InternalMultipartyError::OptimisticMergeNotSupported => None,
             InternalMultipartyError::BitcoinExtractTxError(e) => Some(e),
diff --git a/payjoin/src/receive/multiparty/mod.rs b/payjoin/src/receive/multiparty/mod.rs
index 8719b8e23..722d9ddf6 100644
--- a/payjoin/src/receive/multiparty/mod.rs
+++ b/payjoin/src/receive/multiparty/mod.rs
@@ -1,4 +1,5 @@
 use bitcoin::{FeeRate, Psbt};
+use error::IdenticalProposalError;
 
 use super::error::InputContributionError;
 use super::{v1, v2, Error, ImplementationError, InputPair};
@@ -36,6 +37,30 @@ impl UncheckedProposalBuilder {
         if !params.optimistic_merge {
             return Err(InternalMultipartyError::OptimisticMergeNotSupported.into());
         }
+
+        if let Some(duplicate_context) =
+            self.proposals.iter().find(|c| c.context == proposal.context)
+        {
+            return Err(InternalMultipartyError::IdenticalProposals(
+                IdenticalProposalError::IdenticalContexts(
+                    Box::new(duplicate_context.id()),
+                    Box::new(proposal.id()),
+                ),
+            )
+            .into());
+        };
+
+        if let Some(duplicate_psbt) =
+            self.proposals.iter().find(|psbt| psbt.v1.psbt == proposal.v1.psbt)
+        {
+            return Err(InternalMultipartyError::IdenticalProposals(
+                IdenticalProposalError::IdenticalPsbts(
+                    Box::new(duplicate_psbt.v1.psbt.clone()),
+                    Box::new(proposal.v1.psbt.clone()),
+                ),
+            )
+            .into());
+        }
         Ok(())
     }
 
@@ -218,6 +243,29 @@ impl FinalizedProposal {
                 InternalMultipartyError::ProposalVersionNotSupported(proposal.v1.params.v).into()
             );
         }
+        if let Some(duplicate_context) =
+            self.v2_proposals.iter().find(|c| c.context == proposal.context)
+        {
+            return Err(InternalMultipartyError::IdenticalProposals(
+                IdenticalProposalError::IdenticalContexts(
+                    Box::new(duplicate_context.id()),
+                    Box::new(proposal.id()),
+                ),
+            )
+            .into());
+        };
+
+        if let Some(duplicate_psbt) =
+            self.v2_proposals.iter().find(|psbt| psbt.v1.psbt == proposal.v1.psbt)
+        {
+            return Err(InternalMultipartyError::IdenticalProposals(
+                IdenticalProposalError::IdenticalPsbts(
+                    Box::new(duplicate_psbt.v1.psbt.clone()),
+                    Box::new(proposal.v1.psbt.clone()),
+                ),
+            )
+            .into());
+        }
         Ok(())
     }
 
@@ -253,35 +301,104 @@ impl FinalizedProposal {
 mod test {
 
     use std::any::{Any, TypeId};
-
-    use payjoin_test_utils::{BoxError, PARSED_ORIGINAL_PSBT};
-
-    use super::{v1, v2, FinalizedProposal, UncheckedProposalBuilder, SUPPORTED_VERSIONS};
+    use std::str::FromStr;
+
+    use bitcoin::Psbt;
+    use payjoin_test_utils::{
+        BoxError, MULTIPARTY_ORIGINAL_PSBT_ONE, MULTIPARTY_ORIGINAL_PSBT_TWO,
+    };
+
+    use super::error::IdenticalProposalError;
+    use super::{
+        v1, v2, FinalizedProposal, InternalMultipartyError, MultipartyError,
+        UncheckedProposalBuilder, SUPPORTED_VERSIONS,
+    };
     use crate::receive::optional_parameters::Params;
-    use crate::receive::v2::test::SHARED_CONTEXT;
+    use crate::receive::v2::test::{SHARED_CONTEXT, SHARED_CONTEXT_TWO};
 
-    fn multiparty_proposal_from_test_vector() -> v1::UncheckedProposal {
+    fn multiparty_proposals() -> Vec<v1::UncheckedProposal> {
         let pairs = url::form_urlencoded::parse("v=2&optimisticmerge=true".as_bytes());
         let params = Params::from_query_pairs(pairs, SUPPORTED_VERSIONS)
             .expect("Could not parse from query pairs");
-        v1::UncheckedProposal { psbt: PARSED_ORIGINAL_PSBT.clone(), params }
+
+        [MULTIPARTY_ORIGINAL_PSBT_ONE, MULTIPARTY_ORIGINAL_PSBT_TWO]
+            .iter()
+            .map(|psbt_str| v1::UncheckedProposal {
+                psbt: Psbt::from_str(psbt_str).expect("known psbt should parse"),
+                params: params.clone(),
+            })
+            .collect()
     }
 
     #[test]
-    fn test_build_multiparty() -> Result<(), BoxError> {
+    fn test_single_context_multiparty() -> Result<(), BoxError> {
         let proposal_one = v2::UncheckedProposal {
-            v1: multiparty_proposal_from_test_vector(),
+            v1: multiparty_proposals()[0].clone(),
+            context: SHARED_CONTEXT.clone(),
+        };
+        let mut multiparty = UncheckedProposalBuilder::new();
+        multiparty.add(proposal_one)?;
+        match multiparty.build() {
+            Ok(_) => panic!("multiparty has two identical participants and should error"),
+            Err(e) => assert_eq!(
+                e.to_string(),
+                MultipartyError::from(InternalMultipartyError::NotEnoughProposals).to_string()
+            ),
+        }
+        Ok(())
+    }
+
+    #[test]
+    fn test_duplicate_context_multiparty() -> Result<(), BoxError> {
+        let proposal_one = v2::UncheckedProposal {
+            v1: multiparty_proposals()[0].clone(),
             context: SHARED_CONTEXT.clone(),
         };
         let proposal_two = v2::UncheckedProposal {
-            v1: multiparty_proposal_from_test_vector(),
+            v1: multiparty_proposals()[1].clone(),
             context: SHARED_CONTEXT.clone(),
         };
-        let mut multiparty = UncheckedProposalBuilder::new();
-        multiparty.add(proposal_one)?;
-        multiparty.add(proposal_two)?;
-        let unchecked_proposal = multiparty.build();
-        assert!(unchecked_proposal?.contexts.len() == 2);
+        let mut multiparty = UncheckedProposalBuilder::new().add(proposal_one.clone())?;
+        match multiparty.add(proposal_two.clone()) {
+            Ok(_) => panic!("multiparty has two identical contexts and should error"),
+            Err(e) => assert_eq!(
+                e.to_string(),
+                MultipartyError::from(InternalMultipartyError::IdenticalProposals(
+                    IdenticalProposalError::IdenticalContexts(
+                        Box::new(proposal_one.id()),
+                        Box::new(proposal_two.id())
+                    )
+                ))
+                .to_string()
+            ),
+        }
+        Ok(())
+    }
+
+    #[test]
+    fn test_duplicate_psbt_multiparty() -> Result<(), BoxError> {
+        let proposal_one = v2::UncheckedProposal {
+            v1: multiparty_proposals()[0].clone(),
+            context: SHARED_CONTEXT.clone(),
+        };
+        let proposal_two = v2::UncheckedProposal {
+            v1: multiparty_proposals()[0].clone(),
+            context: SHARED_CONTEXT_TWO.clone(),
+        };
+        let mut multiparty = UncheckedProposalBuilder::new().add(proposal_one.clone())?;
+        match multiparty.add(proposal_two.clone()) {
+            Ok(_) => panic!("multiparty has two identical psbts and should error"),
+            Err(e) => assert_eq!(
+                e.to_string(),
+                MultipartyError::from(InternalMultipartyError::IdenticalProposals(
+                    IdenticalProposalError::IdenticalPsbts(
+                        Box::new(proposal_one.v1.psbt),
+                        Box::new(proposal_two.v1.psbt)
+                    )
+                ))
+                .to_string()
+            ),
+        }
         Ok(())
     }
 
@@ -289,15 +406,15 @@ mod test {
     fn finalize_multiparty() -> Result<(), BoxError> {
         use crate::psbt::PsbtExt;
         let proposal_one = v2::UncheckedProposal {
-            v1: multiparty_proposal_from_test_vector(),
+            v1: multiparty_proposals()[0].clone(),
             context: SHARED_CONTEXT.clone(),
         };
         let proposal_two = v2::UncheckedProposal {
-            v1: multiparty_proposal_from_test_vector(),
-            context: SHARED_CONTEXT.clone(),
+            v1: multiparty_proposals()[1].clone(),
+            context: SHARED_CONTEXT_TWO.clone(),
         };
         let mut finalized_multiparty = FinalizedProposal::new();
-        finalized_multiparty.add(proposal_one.clone())?;
+        finalized_multiparty.add(proposal_one)?;
         assert_eq!(finalized_multiparty.v2()[0].type_id(), TypeId::of::<v2::UncheckedProposal>());
 
         finalized_multiparty.add(proposal_two)?;
diff --git a/payjoin/src/receive/v2/mod.rs b/payjoin/src/receive/v2/mod.rs
index 449dc7c51..5821ecc28 100644
--- a/payjoin/src/receive/v2/mod.rs
+++ b/payjoin/src/receive/v2/mod.rs
@@ -339,6 +339,9 @@ impl UncheckedProposal {
             _ => Err(InternalSessionError::UnexpectedStatusCode(response.status()).into()),
         }
     }
+
+    /// The per-session identifier
+    pub fn id(&self) -> ShortId { id(&self.context.s) }
 }
 
 /// Typestate to validate that the Original PSBT has no receiver-owned inputs.
@@ -657,6 +660,20 @@ pub mod test {
         e: None,
     });
 
+    pub(crate) static SHARED_CONTEXT_TWO: Lazy<SessionContext> = Lazy::new(|| SessionContext {
+        address: Address::from_str("tb1qv7scm7gxs32qg3lnm9kf267kllc63yvdxyh72e")
+            .expect("valid address")
+            .assume_checked(),
+        directory: EXAMPLE_URL.clone(),
+        subdirectory: None,
+        ohttp_keys: OhttpKeys(
+            ohttp::KeyConfig::new(KEY_ID, KEM, Vec::from(SYMMETRIC)).expect("valid key config"),
+        ),
+        expiry: SystemTime::now() + Duration::from_secs(60),
+        s: HpkeKeyPair::gen_keypair(),
+        e: None,
+    });
+
     #[test]
     fn extract_err_req() -> Result<(), BoxError> {
         let mut proposal = UncheckedProposal {


Reviews:
user: login: arminsabouri
body: Core logic looks correct to me. I just had a couple nits. Mainly around test related code.
Thanks for picking this one up :rocket: 
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-17T16:08:39Z
user: login: arminsabouri
body: LGMT. Just one small comment about some commented out code.
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-18T13:26:41Z
user: login: arminsabouri
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-20T13:44:14Z
user: login: arminsabouri
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-24T14:21:10Z
user: login: benalleng
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-24T19:17:16Z
user: login: arminsabouri
body: Ack 587af2dfd85e39d6e9dfbe745bbaed9d64e1ec1e
My only other peice of feedback would be to expose the short id method on session context instead of any particular receiver type state. This out of the scope of this PR but worth noting. 
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-28T16:57:29Z

Review comments:
diff_hunk: @@ -657,6 +657,20 @@ pub mod test {
         e: None,
     });
 
+    pub(crate) static MULTIPARTY_CONTEXT: Lazy<SessionContext> = Lazy::new(|| SessionContext {
path: payjoin/src/receive/v2/mod.rs
user: login: arminsabouri
body: Is there anything that makes this `MULTIPARTY` specific? If not maybe we label this `SHARED_CONTEXT_2` .
Or should we have a common test util to create hpke session contexts? 
created_at: 2025-04-17T15:46:57Z
author_association: COLLABORATOR
diff_hunk: @@ -299,3 +299,7 @@ pub static PARSED_PAYJOIN_PROPOSAL: Lazy<Psbt> =
 pub static PARSED_PAYJOIN_PROPOSAL_WITH_SENDER_INFO: Lazy<Psbt> = Lazy::new(|| {
     Psbt::from_str(PAYJOIN_PROPOSAL_WITH_SENDER_INFO).expect("known psbt should parse")
 });
+
+pub const MULTIPARTY_SENDER_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==";
+
+pub const MULTIPARTY_SENDER_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==";
path: payjoin-test-utils/src/lib.rs
user: login: arminsabouri
body: Nit: If these are PSBTs I would add a `_PSBT` suffix. Additionally, instead of `SENDER` I would use `ORIGINAL` to match the pattern in the rest of this file. Original implies sender psbt
e.g 
`MULTIPARTY_ORIGINAL_PSBT_ONE`. 

created_at: 2025-04-17T15:54:20Z
author_association: COLLABORATOR
diff_hunk: @@ -253,51 +301,124 @@ impl FinalizedProposal {
 mod test {
 
     use std::any::{Any, TypeId};
+    use std::str::FromStr;
 
-    use payjoin_test_utils::{BoxError, PARSED_ORIGINAL_PSBT};
+    use bitcoin::Psbt;
+    use payjoin_test_utils::{BoxError, MULTIPARTY_SENDER_ONE, MULTIPARTY_SENDER_TWO};
 
-    use super::{v1, v2, FinalizedProposal, UncheckedProposalBuilder, SUPPORTED_VERSIONS};
+    use super::error::IdenticalProposalError;
+    use super::{
+        v1, v2, FinalizedProposal, InternalMultipartyError, MultipartyError,
+        UncheckedProposalBuilder, SUPPORTED_VERSIONS,
+    };
     use crate::receive::optional_parameters::Params;
-    use crate::receive::v2::test::SHARED_CONTEXT;
+    use crate::receive::v2::test::{MULTIPARTY_CONTEXT, SHARED_CONTEXT};
 
-    fn multiparty_proposal_from_test_vector() -> v1::UncheckedProposal {
+    fn multiparty_proposal_one() -> v1::UncheckedProposal {
         let pairs = url::form_urlencoded::parse("v=2&optimisticmerge=true".as_bytes());
         let params = Params::from_query_pairs(pairs, SUPPORTED_VERSIONS)
             .expect("Could not parse from query pairs");
-        v1::UncheckedProposal { psbt: PARSED_ORIGINAL_PSBT.clone(), params }
+        v1::UncheckedProposal {
+            psbt: Psbt::from_str(MULTIPARTY_SENDER_ONE).expect("known psbt should parse"),
+            params,
+        }
+    }
+
+    fn multiparty_proposal_two() -> v1::UncheckedProposal {
path: payjoin/src/receive/multiparty/mod.rs
user: login: arminsabouri
body: More of a question: is it worth it to just consolidate this method with the one above and return a Vec of test vectors? I only say this because they seem to share some boilerplate code.
```rust
fn multiparty_proposals() -> Vec<v1::UncheckedProposal> {
    let query = "v=2&optimisticmerge=true";
    let pairs = url::form_urlencoded::parse(query.as_bytes());
    let params = Params::from_query_pairs(pairs, SUPPORTED_VERSIONS)
        .expect("Could not parse from query pairs");

    [MULTIPARTY_SENDER_ONE, MULTIPARTY_SENDER_TWO]
        .iter()
        .map(|psbt_str| v1::UncheckedProposal {
            psbt: Psbt::from_str(psbt_str).expect("known psbt should parse"),
            params,
        })
        .collect()
}
```
created_at: 2025-04-17T16:07:36Z
author_association: COLLABORATOR
diff_hunk: @@ -253,51 +301,130 @@ impl FinalizedProposal {
 mod test {
 
     use std::any::{Any, TypeId};
-
-    use payjoin_test_utils::{BoxError, PARSED_ORIGINAL_PSBT};
-
-    use super::{v1, v2, FinalizedProposal, UncheckedProposalBuilder, SUPPORTED_VERSIONS};
+    use std::str::FromStr;
+
+    use bitcoin::Psbt;
+    use payjoin_test_utils::{
+        BoxError, MULTIPARTY_ORIGINAL_PSBT_ONE, MULTIPARTY_ORIGINAL_PSBT_TWO,
+    };
+
+    use super::error::IdenticalProposalError;
+    use super::{
+        v1, v2, FinalizedProposal, InternalMultipartyError, MultipartyError,
+        UncheckedProposalBuilder, SUPPORTED_VERSIONS,
+    };
     use crate::receive::optional_parameters::Params;
-    use crate::receive::v2::test::SHARED_CONTEXT;
+    use crate::receive::v2::test::{SHARED_CONTEXT, SHARED_CONTEXT_TWO};
 
-    fn multiparty_proposal_from_test_vector() -> v1::UncheckedProposal {
+    fn multiparty_proposals() -> Vec<v1::UncheckedProposal> {
         let pairs = url::form_urlencoded::parse("v=2&optimisticmerge=true".as_bytes());
         let params = Params::from_query_pairs(pairs, SUPPORTED_VERSIONS)
             .expect("Could not parse from query pairs");
-        v1::UncheckedProposal { psbt: PARSED_ORIGINAL_PSBT.clone(), params }
+
+        [MULTIPARTY_ORIGINAL_PSBT_ONE, MULTIPARTY_ORIGINAL_PSBT_TWO]
+            .iter()
+            .map(|psbt_str| v1::UncheckedProposal {
+                psbt: Psbt::from_str(psbt_str).expect("known psbt should parse"),
+                params: params.clone(),
+            })
+            .collect()
+    }
+
+    //fn multiparty_proposal_two() -> v1::UncheckedProposal {
path: payjoin/src/receive/multiparty/mod.rs
user: login: arminsabouri
body: I would just remove this
created_at: 2025-04-18T13:24:01Z
author_association: COLLABORATOR
diff_hunk: @@ -20,6 +24,30 @@ pub(crate) enum InternalMultipartyError {
     FailedToCombinePsbts(bitcoin::psbt::Error),
 }
 
+#[derive(Debug)]
+pub enum IdenticalProposalError {
+    IdenticalPsbts(Box<bitcoin::Psbt>, Box<bitcoin::Psbt>),
+    IdenticalContexts(Box<v2::UncheckedProposal>, Box<v2::UncheckedProposal>),
+}
+
+impl std::fmt::Display for IdenticalProposalError {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            IdenticalProposalError::IdenticalPsbts(current_psbt, incoming_psbt) => write!(
+                f,
+                "Two sender psbts are identical\n left: {}\n right: {}",
+                current_psbt, incoming_psbt
+            ),
+            #[cfg(feature = "v2")]
path: payjoin/src/receive/multiparty/error.rs
user: login: arminsabouri
body: Do we need this feature flag?
created_at: 2025-04-20T13:44:14Z
author_association: COLLABORATOR
diff_hunk: @@ -20,6 +24,30 @@ pub(crate) enum InternalMultipartyError {
     FailedToCombinePsbts(bitcoin::psbt::Error),
 }
 
+#[derive(Debug)]
+pub enum IdenticalProposalError {
+    IdenticalPsbts(Box<bitcoin::Psbt>, Box<bitcoin::Psbt>),
+    IdenticalContexts(Box<v2::UncheckedProposal>, Box<v2::UncheckedProposal>),
path: payjoin/src/receive/multiparty/error.rs
user: login: arminsabouri
body: Is it possible to just pass in the short id when constructing this error variant? I dont think we need the entire unchecked proposal
created_at: 2025-04-24T14:21:10Z
author_association: COLLABORATOR
diff_hunk: @@ -20,6 +24,30 @@ pub(crate) enum InternalMultipartyError {
     FailedToCombinePsbts(bitcoin::psbt::Error),
 }
 
+#[derive(Debug)]
+pub enum IdenticalProposalError {
+    IdenticalPsbts(Box<bitcoin::Psbt>, Box<bitcoin::Psbt>),
+    IdenticalContexts(Box<v2::UncheckedProposal>, Box<v2::UncheckedProposal>),
path: payjoin/src/receive/multiparty/error.rs
user: login: benalleng
body: Ok, moved it over to the mod so just the ShortId is passed through the error
created_at: 2025-04-24T19:17:16Z
author_association: COLLABORATOR

PR comments:
user: login: coveralls
created_at: 2025-04-10T20:19:42Z
body: ## Pull Request Test Coverage Report for [Build 14669174853](https://coveralls.io/builds/73389282)


### Details

* **116** of **136**   **(85.29%)**  changed or added relevant lines in **3** files are covered.
* No unchanged relevant lines lost coverage.
* Overall coverage increased (+**0.1%**) to **82.057%**

---

|  Changes Missing Coverage | Covered Lines | Changed/Added Lines | % |
| :-----|--------------|--------|---: |
| [payjoin/src/receive/multiparty/error.rs](https://coveralls.io/builds/73389282/source?filename=payjoin%2Fsrc%2Freceive%2Fmultiparty%2Ferror.rs#L76) | 13 | 14 | 92.86%
| [payjoin/src/receive/multiparty/mod.rs](https://coveralls.io/builds/73389282/source?filename=payjoin%2Fsrc%2Freceive%2Fmultiparty%2Fmod.rs#L246) | 89 | 108 | 82.41%
<!-- | **Total:** | **116** | **136** | **85.29%** | -->


|  Totals | [![Coverage Status](https://coveralls.io/builds/73389282/badge)](https://coveralls.io/builds/73389282) |
| :-- | --: |
| Change from base [Build 14668777896](https://coveralls.io/builds/73388908): |  0.1% |
| Covered Lines: | 5433 |
| Relevant Lines: | 6621 |

---
##### 💛  - [Coveralls](https://coveralls.io)

author_association: COLLABORATOR
user: login: arminsabouri
created_at: 2025-04-17T16:11:50Z
body: CI failures seems related to https://github.com/payjoin/rust-payjoin/pull/658 which just got merged. So this branch just needs a rebase
author_association: COLLABORATOR
user: login: benalleng
created_at: 2025-04-18T13:48:20Z
body: Whoops, good catch. 

As for the failing CI should we not display the session context in the error?
author_association: COLLABORATOR
user: login: arminsabouri
created_at: 2025-04-18T13:52:59Z
body: > Whoops, good catch.
> 
> As for the failing CI should we not display the session context in the error?

Perhaps just a top level identifier would work fine. The short id should work fine 
author_association: COLLABORATOR
user: login: benalleng
created_at: 2025-04-18T17:08:14Z
body: I kept the SessionContext as what is displayed, I just no longer leak it as accessible in the pub error interface and instead just stringify it
author_association: COLLABORATOR
user: login: arminsabouri
created_at: 2025-04-20T13:39:53Z
body: > I kept the SessionContext as what is displayed, I just no longer leak it as accessible in the pub error interface and instead just stringify it

 The SessionContext does include the hpke keypair. Can you confirm that secret key material is redacted when its stringified.
author_association: COLLABORATOR
user: login: benalleng
created_at: 2025-04-20T20:34:37Z
body: yes the hpke keypair is redacted in the stringify method. But the symmetric pk is not... I think I am just not going to expose the session context after all, what do you think?
```
More than one identical participant: Two sender contexts are identical
 left: SessionContext { address: tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4, directory: Url { scheme: "https", cannot_be_a_base: false, username: "", password: None, host: Some(Domain("exampl
e.com")), port: None, path: "/", query: None, fragment: None }, subdirectory: None, ohttp_keys: OhttpKeys(KeyConfig { key_id: 1, kem: K256Sha256, symmetric: [SymmetricSuite { kdf: HkdfSha256,
 aead: ChaCha20Poly1305 }], sk: Some(PrivateKey 83862a342f732d7d1a97548f501ce02021fbbfdaf5af46ccd8e729d5b006bc90), pk: PublicKey 04d14d8a71d2109c7510063572f321772abb167be99e719628a3fe3250f460
6a8a86569320931680ef34506ddb9987c260c8bd9df5baff62bf5c52b60f4d93cf8e }), expiry: SystemTime { tv_sec: 1745181272, tv_nsec: 721110968 }, s: HpkeKeyPair(SecpHpkeSecretKey([REDACTED]), SecpHpkeP
ublicKey(PublicKey(PublicKey(b3934c5d680367a6e0b9d9b800f2d21b463414d63feca22a7613a8297d4ff2bbcf85ed1ceddf54a2e26257cbf54e03e1f3f2fe64bb6c085f9242c542b8213588)))), e: None }
 right: SessionContext { address: tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4, directory: Url { scheme: "https", cannot_be_a_base: false, username: "", password: None, host: Some(Domain("examp
le.com")), port: None, path: "/", query: None, fragment: None }, subdirectory: None, ohttp_keys: OhttpKeys(KeyConfig { key_id: 1, kem: K256Sha256, symmetric: [SymmetricSuite { kdf: HkdfSha256
, aead: ChaCha20Poly1305 }], sk: Some(PrivateKey 83862a342f732d7d1a97548f501ce02021fbbfdaf5af46ccd8e729d5b006bc90), pk: PublicKey 04d14d8a71d2109c7510063572f321772abb167be99e719628a3fe3250f46
06a8a86569320931680ef34506ddb9987c260c8bd9df5baff62bf5c52b60f4d93cf8e }), expiry: SystemTime { tv_sec: 1745181272, tv_nsec: 721110968 }, s: HpkeKeyPair(SecpHpkeSecretKey([REDACTED]), SecpHpke
PublicKey(PublicKey(PublicKey(b3934c5d680367a6e0b9d9b800f2d21b463414d63feca22a7613a8297d4ff2bbcf85ed1ceddf54a2e26257cbf54e03e1f3f2fe64bb6c085f9242c542b8213588)))), e: None }
```
author_association: COLLABORATOR
user: login: arminsabouri
created_at: 2025-04-22T13:35:48Z
body: > yes the hpke keypair is redacted in the stringify method. But the symmetric pk is not... I think I am just not going to expose the session context after all, what do you think?
> 
> ```
> More than one identical participant: Two sender contexts are identical
>  left: SessionContext { address: tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4, directory: Url { scheme: "https", cannot_be_a_base: false, username: "", password: None, host: Some(Domain("exampl
> e.com")), port: None, path: "/", query: None, fragment: None }, subdirectory: None, ohttp_keys: OhttpKeys(KeyConfig { key_id: 1, kem: K256Sha256, symmetric: [SymmetricSuite { kdf: HkdfSha256,
>  aead: ChaCha20Poly1305 }], sk: Some(PrivateKey 83862a342f732d7d1a97548f501ce02021fbbfdaf5af46ccd8e729d5b006bc90), pk: PublicKey 04d14d8a71d2109c7510063572f321772abb167be99e719628a3fe3250f460
> 6a8a86569320931680ef34506ddb9987c260c8bd9df5baff62bf5c52b60f4d93cf8e }), expiry: SystemTime { tv_sec: 1745181272, tv_nsec: 721110968 }, s: HpkeKeyPair(SecpHpkeSecretKey([REDACTED]), SecpHpkeP
> ublicKey(PublicKey(PublicKey(b3934c5d680367a6e0b9d9b800f2d21b463414d63feca22a7613a8297d4ff2bbcf85ed1ceddf54a2e26257cbf54e03e1f3f2fe64bb6c085f9242c542b8213588)))), e: None }
>  right: SessionContext { address: tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4, directory: Url { scheme: "https", cannot_be_a_base: false, username: "", password: None, host: Some(Domain("examp
> le.com")), port: None, path: "/", query: None, fragment: None }, subdirectory: None, ohttp_keys: OhttpKeys(KeyConfig { key_id: 1, kem: K256Sha256, symmetric: [SymmetricSuite { kdf: HkdfSha256
> , aead: ChaCha20Poly1305 }], sk: Some(PrivateKey 83862a342f732d7d1a97548f501ce02021fbbfdaf5af46ccd8e729d5b006bc90), pk: PublicKey 04d14d8a71d2109c7510063572f321772abb167be99e719628a3fe3250f46
> 06a8a86569320931680ef34506ddb9987c260c8bd9df5baff62bf5c52b60f4d93cf8e }), expiry: SystemTime { tv_sec: 1745181272, tv_nsec: 721110968 }, s: HpkeKeyPair(SecpHpkeSecretKey([REDACTED]), SecpHpke
> PublicKey(PublicKey(PublicKey(b3934c5d680367a6e0b9d9b800f2d21b463414d63feca22a7613a8297d4ff2bbcf85ed1ceddf54a2e26257cbf54e03e1f3f2fe64bb6c085f9242c542b8213588)))), e: None }
> ```

I think short id is fine. 
author_association: COLLABORATOR
user: login: benalleng
created_at: 2025-04-23T16:05:35Z
body: Ended up adding a new `id()` method for `UncheckedProposal` similar to `Receiver::id()` so we don't have to worry about exposing anything we shouldn't.
author_association: COLLABORATOR
user: login: arminsabouri
created_at: 2025-04-24T19:50:46Z
body: Lint is failing. Need https://github.com/payjoin/rust-payjoin/pull/667 to go in first
author_association: COLLABORATOR