Skip to main content

miden_multisig_client/client/
proposals.rs

1//! Proposal workflow operations for MultisigClient.
2//!
3//! This module handles listing, signing, executing, and creating proposals
4//! via GUARDIAN (online mode).
5
6use std::collections::HashSet;
7
8use guardian_shared::{ProposalSignature, ToJson};
9use miden_client::transaction::TransactionRequest;
10
11use super::{MultisigClient, ProposalResult};
12use crate::error::{MultisigError, Result};
13use crate::execution::{
14    SignatureAdvice, SignatureInput, build_final_transaction_request, collect_signature_advice,
15};
16use crate::keystore::proposal_public_key_hex;
17use crate::proposal::{Proposal, TransactionType, is_builtin_proposal_type};
18use crate::transaction::{
19    ProposalBuilder, deserialize_transaction_request, execute_for_summary, word_to_hex,
20};
21
22impl MultisigClient {
23    async fn get_proposal(
24        &mut self,
25        account_id: &miden_protocol::account::AccountId,
26        proposal_id: &str,
27    ) -> Result<Proposal> {
28        let mut guardian_client = self.create_authenticated_guardian_client().await?;
29        let response = guardian_client
30            .get_delta_proposal(account_id, proposal_id)
31            .await
32            .map_err(|e| MultisigError::GuardianServer(format!("failed to get proposal: {}", e)))?;
33
34        let raw_proposal = response
35            .proposal
36            .ok_or_else(|| MultisigError::ProposalNotFound(proposal_id.to_string()))?;
37        Self::ensure_proposal_account_id(&raw_proposal.account_id, account_id)?;
38        let proposal = Proposal::from(&raw_proposal)?;
39        self.verify_proposal_summary_binding(&proposal).await?;
40        Ok(proposal)
41    }
42
43    /// Lists pending proposals for the current account.
44    ///
45    /// Proposals whose nonce is not above the committed account nonce are
46    /// skipped: they have already been executed or superseded, but GUARDIAN may
47    /// still report them pending until canonicalization prunes them.
48    ///
49    /// # Errors
50    ///
51    /// Returns an error if any proposal from GUARDIAN cannot be parsed. This ensures
52    /// malformed GUARDIAN payloads are surfaced rather than silently dropped.
53    pub async fn list_proposals(&mut self) -> Result<Vec<Proposal>> {
54        let (account_id, current_nonce) = {
55            let account = self.require_account()?;
56            (account.id(), account.nonce())
57        };
58
59        let mut guardian_client = self.create_authenticated_guardian_client().await?;
60
61        let response = guardian_client
62            .get_delta_proposals(&account_id)
63            .await
64            .map_err(|e| {
65                MultisigError::GuardianServer(format!("failed to get proposals: {}", e))
66            })?;
67
68        let mut proposals = Vec::with_capacity(response.proposals.len());
69        for delta in &response.proposals {
70            Self::ensure_proposal_account_id(&delta.account_id, &account_id)?;
71            let proposal = Proposal::from(delta)?;
72
73            if proposal.nonce <= current_nonce {
74                continue;
75            }
76
77            self.verify_proposal_summary_binding(&proposal).await?;
78            proposals.push(proposal);
79        }
80
81        Ok(proposals)
82    }
83
84    /// Signs a proposal with the user's key.
85    pub async fn sign_proposal(&mut self, proposal_id: &str) -> Result<Proposal> {
86        let account = self.require_account()?;
87
88        // Check if user is a cosigner
89        let user_commitment = self.key_manager.commitment();
90        if !account.is_cosigner(&user_commitment) {
91            return Err(MultisigError::NotCosigner);
92        }
93
94        let account_id = account.id();
95        let proposal = self.get_proposal(&account_id, proposal_id).await?;
96
97        // Check if already signed
98        if proposal.has_signed(&self.key_manager.commitment_hex()) {
99            return Err(MultisigError::AlreadySigned);
100        }
101
102        // Sign the transaction summary commitment
103        let tx_commitment = proposal.tx_summary.to_commitment();
104        let signature_hex = self.key_manager.sign_word_hex(tx_commitment);
105
106        // Build the ProposalSignature
107        let signature = ProposalSignature::from_scheme(
108            self.key_manager.scheme(),
109            signature_hex,
110            proposal_public_key_hex(self.key_manager.as_ref()),
111        );
112
113        // Push signature to GUARDIAN
114        let mut guardian_client = self.create_authenticated_guardian_client().await?;
115        let sign_response = guardian_client
116            .sign_delta_proposal(&account_id, proposal_id, signature)
117            .await
118            .map_err(|e| {
119                MultisigError::GuardianServer(format!("failed to sign proposal: {}", e))
120            })?;
121
122        let updated_raw = sign_response
123            .delta
124            .as_ref()
125            .ok_or_else(|| MultisigError::ProposalNotFound(proposal_id.to_string()))?;
126        Self::ensure_proposal_account_id(&updated_raw.account_id, &account_id)?;
127        let updated = Proposal::from(updated_raw)?;
128        Ok(updated)
129    }
130
131    /// Executes a proposal when it has enough signatures.
132    ///
133    /// This will:
134    /// 1. Sync with the Miden network to get latest chain state
135    /// 2. Get the proposal and verify it has enough signatures
136    /// 3. Push delta to GUARDIAN to get acknowledgment signature
137    /// 4. Build the transaction with all cosigner signatures + GUARDIAN ack
138    /// 5. Execute the transaction on-chain
139    /// 6. Sync and update local account state
140    ///
141    /// A proposal whose nonce is not strictly above the committed account nonce
142    /// is rejected: it has already been executed or superseded, and re-executing
143    /// would double-apply the intent and corrupt the nonce/delta sequence
144    /// GUARDIAN tracks for canonicalization.
145    ///
146    /// `SwitchGuardian` carries no GUARDIAN ack in the transaction itself (the
147    /// switch happens later in `finalize_transaction`), but its delta is still
148    /// pushed to the pre-switch GUARDIAN so it canonicalizes like any other
149    /// proposal. That push is best-effort: an unreachable GUARDIAN must not block
150    /// the switch, so the ack and any error are discarded.
151    pub async fn execute_proposal(&mut self, proposal_id: &str) -> Result<()> {
152        // Sync with the network before executing to ensure we have latest state
153        self.sync().await?;
154
155        let account = self.require_account()?.clone();
156        let account_id = account.id();
157
158        let proposal = self.get_proposal(&account_id, proposal_id).await?;
159
160        if proposal.nonce <= account.nonce() {
161            return Err(MultisigError::InvalidConfig(format!(
162                "proposal nonce {} is not greater than the current account nonce {}; \
163                 it has already been executed or superseded",
164                proposal.nonce,
165                account.nonce()
166            )));
167        }
168
169        // Verify proposal is ready (has enough signatures)
170        if !proposal.status.is_ready() {
171            let (collected, required) = proposal.signature_counts();
172            return Err(MultisigError::ProposalNotReady {
173                collected,
174                required,
175            });
176        }
177
178        // Custom proposals (issue #266) have no per-type reconstruction recipe,
179        // so the SDK cannot build/submit them. The integration executes them
180        // with its own recipe using the advice from `prepare_custom_execution`.
181        if matches!(proposal.transaction_type, TransactionType::Custom) {
182            return Err(MultisigError::UnsupportedTransactionType(
183                "custom proposals are executed by the integration; call \
184                 prepare_custom_execution to get the cosigner + GUARDIAN advice"
185                    .to_string(),
186            ));
187        }
188
189        let tx_summary_commitment = proposal.tx_summary.to_commitment();
190
191        let mut signature_inputs: Vec<SignatureInput> = proposal
192            .signatures
193            .into_iter()
194            .map(|signature| SignatureInput {
195                signer_commitment: signature.signer_commitment,
196                signature_hex: signature.signature_hex,
197                scheme: signature.scheme,
198                public_key_hex: signature.public_key_hex,
199            })
200            .collect();
201
202        // Deduplicate by signer commitment
203        signature_inputs.sort_by(|a, b| a.signer_commitment.cmp(&b.signer_commitment));
204        signature_inputs.dedup_by(|a, b| a.signer_commitment == b.signer_commitment);
205
206        // Build signature advice from cosigner signatures
207        // Important: Use CURRENT account signers for validation, not proposal's new signers.
208        // The on-chain MASM verifies signatures against the currently stored public keys.
209        let required_commitments: HashSet<String> =
210            account.cosigner_commitments_hex().into_iter().collect();
211        let mut signature_advice = collect_signature_advice(
212            signature_inputs,
213            &required_commitments,
214            tx_summary_commitment,
215        )?;
216
217        if proposal.transaction_type.requires_guardian_ack() {
218            // Get GUARDIAN ack signature and add to advice
219            let guardian_advice = self
220                .get_guardian_ack_signature(
221                    &account,
222                    proposal.nonce,
223                    &proposal.tx_summary,
224                    tx_summary_commitment,
225                )
226                .await?;
227            signature_advice.push(guardian_advice);
228        } else {
229            let _ = self
230                .get_guardian_ack_signature(
231                    &account,
232                    proposal.nonce,
233                    &proposal.tx_summary,
234                    tx_summary_commitment,
235                )
236                .await;
237        }
238
239        // Build the final transaction request with all signatures
240        let salt = proposal.metadata.salt()?;
241
242        // For signer-update transactions, we must propagate parse errors for signer commitments
243        // rather than silently converting to None. This ensures malformed hex is diagnosed properly.
244        let signer_commitments = if matches!(
245            &proposal.transaction_type,
246            TransactionType::AddCosigner { .. }
247                | TransactionType::RemoveCosigner { .. }
248                | TransactionType::UpdateSigners { .. }
249        ) {
250            Some(proposal.metadata.signer_commitments()?)
251        } else {
252            proposal.metadata.signer_commitments().ok()
253        };
254
255        let final_tx_request = build_final_transaction_request(
256            &self.miden_client,
257            &proposal.transaction_type,
258            account.inner(),
259            salt,
260            signature_advice,
261            proposal.metadata.new_threshold,
262            signer_commitments.as_deref(),
263            self.key_manager.scheme(),
264        )
265        .await?;
266
267        // Execute and finalize
268        self.finalize_transaction(account_id, final_tx_request, &proposal.transaction_type)
269            .await
270    }
271
272    /// Creates a proposal from a producer-built transaction the SDK does not
273    /// model (issue #266 producer API). `transaction_request_bytes` is a serialized
274    /// `TransactionRequest`; `proposal_type` is a free-form, non-empty label
275    /// that MUST NOT collide with a built-in type. The integration keeps its own
276    /// recipe to execute later via `prepare_custom_execution`.
277    pub async fn propose_custom_transaction(
278        &mut self,
279        transaction_request_bytes: &[u8],
280        proposal_type: &str,
281    ) -> Result<Proposal> {
282        let proposal_type = proposal_type.trim().to_lowercase();
283        if proposal_type.is_empty() {
284            return Err(MultisigError::InvalidConfig(
285                "proposal_type must not be empty".to_string(),
286            ));
287        }
288        if !proposal_type
289            .bytes()
290            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')
291        {
292            return Err(MultisigError::InvalidConfig(format!(
293                "proposal_type '{}' must be lowercase snake_case ([a-z0-9_]): no spaces, hyphens, or other characters",
294                proposal_type
295            )));
296        }
297        if is_builtin_proposal_type(&proposal_type) {
298            return Err(MultisigError::UnsupportedTransactionType(format!(
299                "'{}' is a built-in proposal type; use the typed proposal API instead",
300                proposal_type
301            )));
302        }
303
304        self.sync().await?;
305        let account = self.require_account()?.clone();
306        let account_id = account.id();
307
308        let tx_request = deserialize_transaction_request(transaction_request_bytes)?;
309        let tx_summary =
310            execute_for_summary(&mut self.miden_client, account_id, tx_request).await?;
311        let tx_commitment = tx_summary.to_commitment();
312
313        let required_signatures = account.threshold()? as usize;
314
315        let metadata = crate::proposal::ProposalMetadata {
316            tx_summary_json: Some(tx_summary.to_json()),
317            proposal_type: Some(proposal_type.to_string()),
318            required_signatures: Some(required_signatures),
319            signers: vec![self.key_manager.commitment_hex()],
320            ..Default::default()
321        };
322
323        let payload = crate::payload::ProposalPayload::new(&tx_summary)
324            .with_signature(self.key_manager.as_ref(), tx_commitment)
325            .with_custom_metadata(proposal_type.to_string())
326            .with_required_signatures(required_signatures);
327
328        let nonce = account.nonce() + 1;
329        let mut guardian_client = self.create_authenticated_guardian_client().await?;
330        let response = guardian_client
331            .push_delta_proposal(&account_id, nonce, &payload.to_json())
332            .await
333            .map_err(|e| {
334                MultisigError::GuardianServer(format!("failed to push proposal: {}", e))
335            })?;
336
337        let proposal = Proposal::new(tx_summary, nonce, TransactionType::Custom, metadata);
338
339        if !proposal
340            .id
341            .trim_start_matches("0x")
342            .eq_ignore_ascii_case(response.commitment.trim_start_matches("0x"))
343        {
344            return Err(MultisigError::GuardianServer(format!(
345                "GUARDIAN returned proposal commitment {} but expected {}",
346                response.commitment, proposal.id
347            )));
348        }
349
350        Ok(proposal)
351    }
352
353    /// Assembles the validated execution advice for a threshold-met custom
354    /// proposal (issue #266 producer API): the cosigner signatures and the
355    /// GUARDIAN acknowledgment, keyed for the transaction's advice map. The
356    /// integration injects this into its own rebuilt transaction request
357    /// (`request.advice_map_mut().extend(advice)`) and submits it via its own
358    /// Miden client.
359    ///
360    /// `transaction_request_bytes` (the serialized transaction request) is used only to verify,
361    /// before the acknowledgment is requested, that it reproduces the signed
362    /// proposal commitment. On a not-ready proposal or a binding mismatch this
363    /// fails before requesting the acknowledgment.
364    pub async fn prepare_custom_execution(
365        &mut self,
366        proposal_id: &str,
367        transaction_request_bytes: &[u8],
368    ) -> Result<Vec<SignatureAdvice>> {
369        self.sync().await?;
370        let account = self.require_account()?.clone();
371        let account_id = account.id();
372
373        let proposal = self.get_proposal(&account_id, proposal_id).await?;
374
375        if !matches!(proposal.transaction_type, TransactionType::Custom) {
376            return Err(MultisigError::UnsupportedTransactionType(
377                "prepare_custom_execution is only for custom proposals; use execute_proposal \
378                 for built-in types"
379                    .to_string(),
380            ));
381        }
382
383        if !proposal.status.is_ready() {
384            let (collected, required) = proposal.signature_counts();
385            return Err(MultisigError::ProposalNotReady {
386                collected,
387                required,
388            });
389        }
390
391        let tx_summary_commitment = proposal.tx_summary.to_commitment();
392
393        let probe_request = deserialize_transaction_request(transaction_request_bytes)?;
394        let derived_summary =
395            execute_for_summary(&mut self.miden_client, account_id, probe_request).await?;
396        let derived_commitment = derived_summary.to_commitment();
397        if derived_commitment != tx_summary_commitment {
398            return Err(MultisigError::InvalidConfig(format!(
399                "transaction request does not match the signed proposal commitment \
400                 (expected {}, got {})",
401                word_to_hex(&tx_summary_commitment),
402                word_to_hex(&derived_commitment)
403            )));
404        }
405
406        let mut signature_inputs: Vec<SignatureInput> = proposal
407            .signatures
408            .into_iter()
409            .map(|signature| SignatureInput {
410                signer_commitment: signature.signer_commitment,
411                signature_hex: signature.signature_hex,
412                scheme: signature.scheme,
413                public_key_hex: signature.public_key_hex,
414            })
415            .collect();
416        signature_inputs.sort_by(|a, b| a.signer_commitment.cmp(&b.signer_commitment));
417        signature_inputs.dedup_by(|a, b| a.signer_commitment == b.signer_commitment);
418
419        let required_commitments: HashSet<String> =
420            account.cosigner_commitments_hex().into_iter().collect();
421        let mut signature_advice = collect_signature_advice(
422            signature_inputs,
423            &required_commitments,
424            tx_summary_commitment,
425        )?;
426
427        if proposal.transaction_type.requires_guardian_ack() {
428            let guardian_advice = self
429                .get_guardian_ack_signature(
430                    &account,
431                    proposal.nonce,
432                    &derived_summary,
433                    tx_summary_commitment,
434                )
435                .await?;
436            signature_advice.push(guardian_advice);
437        }
438
439        Ok(signature_advice)
440    }
441
442    /// Submits an integration-built transaction on-chain (issue #266 producer
443    /// API). The caller injects the advice from `prepare_custom_execution` into
444    /// its own transaction request (`request.advice_map_mut().extend(advice)`)
445    /// and passes it here to finalize.
446    pub async fn submit_transaction(&mut self, request: TransactionRequest) -> Result<()> {
447        // Refresh local state first: the account may have advanced between
448        // `prepare_custom_execution` and submit, and submitting against stale
449        // state would reject an otherwise-valid request.
450        self.sync().await?;
451        let account_id = self.require_account()?.id();
452        self.miden_client
453            .submit_new_transaction(account_id, request)
454            .await
455            .map_err(|e| {
456                MultisigError::TransactionExecution(format!(
457                    "transaction submission failed: {:?}",
458                    e
459                ))
460            })?;
461        let _ = self.miden_client.sync_state().await;
462        Ok(())
463    }
464
465    /// Creates a proposal for a transaction.
466    ///
467    /// This is the primary API for creating multisig transaction proposals.
468    /// It handles all transaction types through a unified interface.
469    ///
470    /// # Example
471    ///
472    /// ```ignore
473    /// use miden_multisig_client::TransactionType;
474    ///
475    /// // Add a new cosigner
476    /// let proposal = client.propose_transaction(
477    ///     TransactionType::AddCosigner { new_commitment }
478    /// ).await?;
479    ///
480    /// // Remove a cosigner
481    /// let proposal = client.propose_transaction(
482    ///     TransactionType::RemoveCosigner { commitment }
483    /// ).await?;
484    /// ```
485    pub async fn propose_transaction(
486        &mut self,
487        transaction_type: TransactionType,
488    ) -> Result<Proposal> {
489        // Sync with the network before executing transaction
490        self.sync().await?;
491
492        let account = self.require_account()?.clone();
493        let mut guardian_client = self.create_authenticated_guardian_client().await?;
494
495        ProposalBuilder::new(transaction_type)
496            .build(
497                &mut self.miden_client,
498                &mut guardian_client,
499                &account,
500                self.key_manager.as_ref(),
501            )
502            .await
503    }
504
505    /// Proposes a transaction with automatic fallback to offline mode.
506    ///
507    /// First attempts to create the proposal via GUARDIAN. If GUARDIAN is unavailable
508    /// (connection error), falls back to offline proposal creation only when
509    /// the transaction supports GUARDIAN-less execution (`SwitchGuardian`).
510    ///
511    /// This is useful when you want to attempt online coordination but have a
512    /// graceful fallback path for offline sharing.
513    ///
514    /// # Returns
515    ///
516    /// - `ProposalResult::Online(Proposal)` if GUARDIAN succeeded
517    /// - `ProposalResult::Offline(ExportedProposal)` if GUARDIAN failed and transaction is `SwitchGuardian`
518    ///
519    /// # Example
520    ///
521    /// ```ignore
522    /// use miden_multisig_client::{TransactionType, ProposalResult};
523    ///
524    /// let tx = TransactionType::switch_guardian("https://new-guardian.example.com", new_guardian_commitment);
525    /// let result = client.propose_with_fallback(
526    ///     tx
527    /// ).await?;
528    ///
529    /// match result {
530    ///     ProposalResult::Online(proposal) => {
531    ///         println!("Proposal {} created on GUARDIAN", proposal.id);
532    ///     }
533    ///     ProposalResult::Offline(exported) => {
534    ///         println!("GUARDIAN unavailable, share this file with cosigners:");
535    ///         std::fs::write("proposal.json", exported.to_json()?)?;
536    ///     }
537    /// }
538    /// ```
539    pub async fn propose_with_fallback(
540        &mut self,
541        transaction_type: TransactionType,
542    ) -> Result<ProposalResult> {
543        // Try online first
544        match self.propose_transaction(transaction_type.clone()).await {
545            Ok(proposal) => Ok(ProposalResult::Online(Box::new(proposal))),
546            Err(
547                error @ (MultisigError::GuardianConnection(_) | MultisigError::GuardianServer(_)),
548            ) => {
549                if transaction_type.supports_offline_execution() {
550                    let exported = self.create_proposal_offline(transaction_type).await?;
551                    Ok(ProposalResult::Offline(Box::new(exported)))
552                } else {
553                    Err(error)
554                }
555            }
556            Err(e) => Err(e),
557        }
558    }
559}
560
561#[cfg(test)]
562mod tests {
563    use guardian_client::DeltaObject;
564    use guardian_shared::ToJson;
565    use miden_protocol::account::AccountId;
566    use miden_protocol::account::delta::{AccountDelta, AccountStorageDelta, AccountVaultDelta};
567    use miden_protocol::transaction::{InputNotes, RawOutputNotes, TransactionSummary};
568    use miden_protocol::{Felt, Word, ZERO};
569
570    use crate::error::{MultisigError, Result};
571    use crate::proposal::Proposal;
572
573    fn create_test_tx_summary(account_id: &str, seed: u64) -> TransactionSummary {
574        let account_id = AccountId::from_hex(account_id).expect("valid account id");
575        let account_delta = AccountDelta::new(
576            account_id,
577            AccountStorageDelta::default(),
578            AccountVaultDelta::default(),
579            Felt::ZERO,
580        )
581        .expect("valid delta");
582
583        TransactionSummary::new(
584            account_delta,
585            InputNotes::new(Vec::new()).expect("empty input notes"),
586            RawOutputNotes::new(Vec::new()).expect("empty output notes"),
587            Word::from([Felt::new_unchecked(seed), ZERO, ZERO, ZERO]),
588        )
589    }
590
591    fn proposal_delta(
592        account_id: &str,
593        nonce: u64,
594        new_commitment: &str,
595        seed: u64,
596    ) -> DeltaObject {
597        let payload = serde_json::json!({
598            "tx_summary": create_test_tx_summary(account_id, seed).to_json(),
599            "signatures": [],
600            "metadata": {
601                "proposal_type": "switch_guardian",
602                "required_signatures": 1,
603                "new_guardian_pubkey": "0x0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20",
604                "new_guardian_endpoint": "http://new-guardian.example.com"
605            }
606        });
607
608        DeltaObject {
609            account_id: account_id.to_string(),
610            nonce,
611            prev_commitment: "0x000".to_string(),
612            delta_payload: serde_json::to_string(&payload).expect("payload serialization"),
613            new_commitment: new_commitment.to_string(),
614            ack_sig: String::new(),
615            ack_pubkey: None,
616            ack_scheme: None,
617            candidate_at: String::new(),
618            canonical_at: None,
619            discarded_at: None,
620            status: None,
621        }
622    }
623
624    #[test]
625    fn inline_iteration_selects_by_unique_id_when_nonce_collides() {
626        let same_nonce = 42;
627        let delta_a = proposal_delta("0x7b7b7b7a7b7b7b017b7b7b7b7b7b7b", same_nonce, "0xaaa", 1);
628        let delta_b = proposal_delta("0x7c7c7c7c7c7c7c017c7c7c7c7c7c7c", same_nonce, "0xbbb", 2);
629
630        let target = Proposal::from(&delta_b).expect("proposal parses");
631
632        let proposals = [delta_a, delta_b.clone()];
633        let mut matched: Option<(&DeltaObject, Proposal)> = None;
634        for raw_proposal in &proposals {
635            let parsed = Proposal::from(raw_proposal).expect("parses");
636            if parsed.id == target.id {
637                matched = Some((raw_proposal, parsed));
638            }
639        }
640        let (raw, parsed) = matched.expect("proposal should be found");
641
642        assert_eq!(parsed.id, target.id);
643        assert_eq!(parsed.nonce, same_nonce);
644        assert_eq!(raw.new_commitment, delta_b.new_commitment);
645    }
646
647    #[test]
648    fn inline_iteration_rejects_duplicate_ids() {
649        let delta = proposal_delta("0x7b7b7b7a7b7b7b017b7b7b7b7b7b7b", 42, "0xaaa", 1);
650        let proposal_id = Proposal::from(&delta).expect("proposal parses").id;
651
652        let mut matched: Option<(&DeltaObject, Proposal)> = None;
653        let err = (&[delta.clone(), delta] as &[DeltaObject])
654            .iter()
655            .try_for_each(|raw_proposal| -> Result<()> {
656                let parsed = Proposal::from(raw_proposal)?;
657                if parsed.id == proposal_id {
658                    if matched.is_some() {
659                        return Err(MultisigError::InvalidConfig(format!(
660                            "multiple proposals returned with the same ID {}",
661                            proposal_id
662                        )));
663                    }
664                    matched = Some((raw_proposal, parsed));
665                }
666                Ok(())
667            })
668            .expect_err("duplicate ids should fail");
669
670        match err {
671            MultisigError::InvalidConfig(message) => {
672                assert!(message.contains("multiple proposals returned with the same ID"));
673            }
674            other => panic!("unexpected error: {other:?}"),
675        }
676    }
677}