Skip to main content

miden_multisig_client/
proposal.rs

1//! Proposal types and utilities for multisig transactions.
2
3use std::collections::HashSet;
4
5use base64::Engine;
6use base64::engine::general_purpose::STANDARD as BASE64;
7use guardian_client::DeltaObject;
8use guardian_shared::FromJson;
9use guardian_shared::hex::FromHex;
10use guardian_shared::{ProposalSignature, SignatureScheme};
11use miden_protocol::account::AccountId;
12use miden_protocol::crypto::dsa::ecdsa_k256_keccak::{
13    PublicKey as EcdsaPublicKey, Signature as EcdsaSignature,
14};
15use miden_protocol::crypto::dsa::falcon512_poseidon2::Signature as Poseidon2FalconSignature;
16use miden_protocol::note::{Note, NoteId, NoteType};
17use miden_protocol::transaction::TransactionSummary;
18use miden_protocol::utils::serde::{Deserializable, Serializable};
19use miden_protocol::{Felt, Word};
20use serde::{Deserialize, Serialize};
21use serde_json::Value;
22
23use crate::error::{MultisigError, Result};
24use crate::keystore::{ensure_hex_prefix, word_from_hex};
25use crate::payload::ProposalPayload;
26use crate::procedures::ProcedureName;
27
28/// Max serialized v2 `consume_notes` metadata, enforced at creation. Spec FR-011.
29pub const MAX_CONSUME_NOTES_METADATA_BYTES: usize = 256 * 1024;
30
31/// `consume_notes` metadata schema version. Absence on the wire => v1 (legacy).
32pub const CONSUME_NOTES_METADATA_VERSION_V2: u32 = 2;
33
34/// Base64 of `Serializable::to_bytes(&note)`. Inner string is private so
35/// every construction goes through `from_note` or `from_base64`.
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(transparent)]
38pub struct SerializedNote(String);
39
40impl SerializedNote {
41    pub fn from_note(note: &Note) -> Self {
42        Self(BASE64.encode(Serializable::to_bytes(note)))
43    }
44
45    /// Wraps an already-base64-encoded wire string. Validation is deferred to `to_note`.
46    pub fn from_base64(s: String) -> Self {
47        Self(s)
48    }
49
50    pub fn as_str(&self) -> &str {
51        &self.0
52    }
53
54    pub fn into_inner(self) -> String {
55        self.0
56    }
57
58    pub fn to_note(&self) -> Result<Note> {
59        let bytes = BASE64
60            .decode(self.0.as_bytes())
61            .map_err(|e| MultisigError::InvalidConfig(format!("invalid base64 in note: {}", e)))?;
62        Note::read_from_bytes(&bytes)
63            .map_err(|e| MultisigError::InvalidConfig(format!("failed to deserialize note: {}", e)))
64    }
65}
66
67/// Status of a proposal in the signing workflow.
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub enum ProposalStatus {
70    Pending,
71    Ready,
72    Finalized,
73}
74
75impl ProposalStatus {
76    pub fn is_ready(&self) -> bool {
77        matches!(self, ProposalStatus::Ready)
78    }
79
80    pub fn is_pending(&self) -> bool {
81        matches!(self, ProposalStatus::Pending)
82    }
83}
84
85/// Types of transactions supported by the multisig SDK.
86///
87/// Marked `#[non_exhaustive]` so future proposal types (including evolutions of
88/// the custom-type support, issue #266) can be added without breaking external
89/// crates: downstream `match` statements must already include a wildcard arm.
90#[derive(Debug, Clone, PartialEq, Eq)]
91#[non_exhaustive]
92pub enum TransactionType {
93    P2ID {
94        recipient: AccountId,
95        faucet_id: AccountId,
96        amount: u64,
97        /// Visibility of the created note (issue #322). Absent in legacy
98        /// proposal metadata, which maps to [`NoteType::Public`].
99        note_type: NoteType,
100    },
101    ConsumeNotes {
102        note_ids: Vec<NoteId>,
103        /// `None`/`Some(1)` => v1, `Some(2)` => v2 (issue #229).
104        metadata_version: Option<u32>,
105        /// v2 embedded notes, index-aligned with `note_ids`. Empty on v1.
106        notes: Vec<SerializedNote>,
107    },
108    AddCosigner {
109        new_commitment: Word,
110    },
111    RemoveCosigner {
112        commitment: Word,
113    },
114    SwitchGuardian {
115        new_endpoint: String,
116        new_commitment: Word,
117    },
118    UpdateProcedureThreshold {
119        procedure: ProcedureName,
120        new_threshold: u32,
121    },
122    UpdateSigners {
123        new_threshold: u32,
124        signer_commitments: Vec<Word>,
125    },
126    /// A custom proposal whose `proposal_type` the SDK does not model (issue
127    /// #266). Can be parsed, listed, signed, and exported/imported; the original
128    /// label is preserved in `ProposalMetadata.proposal_type`. The generic SDK
129    /// cannot build or execute the on-chain transaction for a custom procedure;
130    /// the integration that owns the recipe drives execution via
131    /// `prepare_custom_execution`.
132    Custom,
133}
134
135impl TransactionType {
136    /// Creates a P2ID transfer transaction with a public output note.
137    pub fn transfer(recipient: AccountId, faucet_id: AccountId, amount: u64) -> Self {
138        Self::transfer_with_note_type(recipient, faucet_id, amount, NoteType::Public)
139    }
140
141    /// Creates a P2ID transfer transaction with an explicit note visibility
142    /// (issue #322).
143    pub fn transfer_with_note_type(
144        recipient: AccountId,
145        faucet_id: AccountId,
146        amount: u64,
147        note_type: NoteType,
148    ) -> Self {
149        Self::P2ID {
150            recipient,
151            faucet_id,
152            amount,
153            note_type,
154        }
155    }
156
157    /// Legacy (v1) — no embedded notes. Use `consume_notes_v2` for new proposals.
158    pub fn consume_notes(note_ids: Vec<NoteId>) -> Self {
159        Self::ConsumeNotes {
160            note_ids,
161            metadata_version: None,
162            notes: Vec::new(),
163        }
164    }
165
166    /// v2 — embeds the notes inline so verification doesn't read the local store (issue #229).
167    pub fn consume_notes_v2(note_ids: Vec<NoteId>, notes: Vec<SerializedNote>) -> Self {
168        Self::ConsumeNotes {
169            note_ids,
170            metadata_version: Some(CONSUME_NOTES_METADATA_VERSION_V2),
171            notes,
172        }
173    }
174
175    /// Creates an AddCosigner transaction.
176    pub fn add_cosigner(new_commitment: Word) -> Self {
177        Self::AddCosigner { new_commitment }
178    }
179
180    /// Creates a RemoveCosigner transaction.
181    pub fn remove_cosigner(commitment: Word) -> Self {
182        Self::RemoveCosigner { commitment }
183    }
184
185    /// Creates a SwitchGuardian transaction.
186    pub fn switch_guardian(new_endpoint: impl Into<String>, new_commitment: Word) -> Self {
187        Self::SwitchGuardian {
188            new_endpoint: new_endpoint.into(),
189            new_commitment,
190        }
191    }
192
193    /// Creates an UpdateProcedureThreshold transaction.
194    pub fn update_procedure_threshold(procedure: ProcedureName, new_threshold: u32) -> Self {
195        Self::UpdateProcedureThreshold {
196            procedure,
197            new_threshold,
198        }
199    }
200
201    /// Creates an UpdateSigners transaction.
202    pub fn update_signers(new_threshold: u32, signer_commitments: Vec<Word>) -> Self {
203        Self::UpdateSigners {
204            new_threshold,
205            signer_commitments,
206        }
207    }
208
209    pub(crate) fn proposal_type(&self) -> Option<&'static str> {
210        match self {
211            Self::P2ID { .. } => Some("p2id"),
212            Self::ConsumeNotes { .. } => Some("consume_notes"),
213            Self::AddCosigner { .. } => Some("add_signer"),
214            Self::RemoveCosigner { .. } => Some("remove_signer"),
215            Self::SwitchGuardian { .. } => Some("switch_guardian"),
216            Self::UpdateProcedureThreshold { .. } => Some("update_procedure_threshold"),
217            Self::UpdateSigners { .. } => None,
218            Self::Custom => None,
219        }
220    }
221
222    /// Returns a stable transaction type name for diagnostics and flow checks.
223    pub fn type_name(&self) -> &'static str {
224        match self {
225            Self::P2ID { .. } => "P2ID",
226            Self::ConsumeNotes { .. } => "ConsumeNotes",
227            Self::AddCosigner { .. } => "AddCosigner",
228            Self::RemoveCosigner { .. } => "RemoveCosigner",
229            Self::SwitchGuardian { .. } => "SwitchGuardian",
230            Self::UpdateProcedureThreshold { .. } => "UpdateProcedureThreshold",
231            Self::UpdateSigners { .. } => "UpdateSigners",
232            Self::Custom => "Custom",
233        }
234    }
235
236    /// Returns true when execution can proceed without a GUARDIAN acknowledgment.
237    pub fn supports_offline_execution(&self) -> bool {
238        matches!(self, Self::SwitchGuardian { .. })
239    }
240
241    /// Returns true when execution requires a GUARDIAN acknowledgment signature.
242    pub fn requires_guardian_ack(&self) -> bool {
243        !self.supports_offline_execution()
244    }
245}
246
247/// Proposal type labels the SDK models natively. The producer (`propose_custom_transaction`)
248/// path rejects these so an opaque transaction can never be mis-routed to a
249/// built-in handler.
250const BUILTIN_PROPOSAL_TYPES: &[&str] = &[
251    "add_signer",
252    "remove_signer",
253    "change_threshold",
254    "update_procedure_threshold",
255    "switch_guardian",
256    "consume_notes",
257    "p2id",
258    // Reserved: the SDK's internal bucket name for unmodeled types. A producer
259    // must not use it as a custom label, or it would collide with the bucket.
260    "custom",
261];
262
263pub(crate) fn is_builtin_proposal_type(proposal_type: &str) -> bool {
264    BUILTIN_PROPOSAL_TYPES.contains(&proposal_type)
265}
266
267/// Metadata needed to reconstruct and finalize a proposal.
268#[derive(Debug, Clone, Default)]
269pub struct ProposalMetadata {
270    pub tx_summary_json: Option<Value>,
271    pub proposal_type: Option<String>,
272    pub new_threshold: Option<u64>,
273    pub signer_commitments_hex: Vec<String>,
274    pub salt_hex: Option<String>,
275
276    pub recipient_hex: Option<String>,
277    pub faucet_id_hex: Option<String>,
278    pub amount: Option<u64>,
279    /// P2ID note visibility, `"public"` or `"private"` (issue #322).
280    /// `None` => public, the wire shape of pre-#322 proposals.
281    pub note_type: Option<String>,
282
283    pub note_ids_hex: Vec<String>,
284
285    /// `consume_notes` metadata version. `None` => v1, `Some(2)` => v2.
286    /// Other values are rejected at dispatch (spec FR-009).
287    pub consume_notes_metadata_version: Option<u32>,
288
289    /// v2 embedded notes, index-aligned with `note_ids_hex`. Empty on v1.
290    pub consume_notes_notes: Vec<SerializedNote>,
291
292    pub new_guardian_pubkey_hex: Option<String>,
293    pub new_guardian_endpoint: Option<String>,
294    pub target_procedure: Option<String>,
295
296    pub required_signatures: Option<usize>,
297    pub signers: Vec<String>,
298}
299
300impl ProposalMetadata {
301    pub fn is_consume_notes_v1(&self) -> bool {
302        matches!(self.consume_notes_metadata_version, None | Some(1))
303    }
304
305    pub fn is_consume_notes_v2(&self) -> bool {
306        self.consume_notes_metadata_version == Some(CONSUME_NOTES_METADATA_VERSION_V2)
307    }
308
309    /// Converts salt hex to Word.
310    pub fn salt(&self) -> Result<Word> {
311        match &self.salt_hex {
312            Some(value) => word_from_hex(value).map_err(MultisigError::InvalidConfig),
313            None => Ok(Word::from([Felt::new_unchecked(0); 4])),
314        }
315    }
316
317    /// Converts signer commitments to Words.
318    pub fn signer_commitments(&self) -> Result<Vec<Word>> {
319        let mut seen = HashSet::new();
320        let mut commitments = Vec::with_capacity(self.signer_commitments_hex.len());
321
322        for hex in &self.signer_commitments_hex {
323            let commitment = word_from_hex(hex).map_err(MultisigError::InvalidConfig)?;
324            let key = ensure_hex_prefix(hex).to_lowercase();
325            if !seen.insert(key) {
326                return Err(MultisigError::InvalidConfig(format!(
327                    "duplicate signer commitment in metadata: {}",
328                    hex
329                )));
330            }
331            commitments.push(commitment);
332        }
333
334        Ok(commitments)
335    }
336
337    /// Parses `note_type` for a P2ID proposal. Absent => public (the only
338    /// behavior before issue #322); an unrecognized value is rejected rather
339    /// than silently rebuilt as a public note that could never match the
340    /// signed tx_summary commitment.
341    pub fn p2id_note_type(&self) -> Result<NoteType> {
342        match self.note_type.as_deref() {
343            None => Ok(NoteType::Public),
344            Some(value) => value.parse().map_err(|_| {
345                MultisigError::InvalidConfig(format!(
346                    "unsupported metadata.note_type '{}': expected 'public' or 'private'",
347                    value
348                ))
349            }),
350        }
351    }
352
353    /// Converts note ID hex strings to NoteIds.
354    pub fn note_ids(&self) -> Result<Vec<NoteId>> {
355        self.note_ids_hex
356            .iter()
357            .map(|hex| {
358                let word = word_from_hex(hex).map_err(MultisigError::InvalidConfig)?;
359                Ok(NoteId::from_raw(word))
360            })
361            .collect()
362    }
363
364    pub(crate) fn to_transaction_type(&self, proposal_type: &str) -> Result<TransactionType> {
365        if proposal_type.is_empty() {
366            return Err(MultisigError::InvalidConfig(
367                "proposal metadata.proposal_type is required".to_string(),
368            ));
369        }
370
371        match proposal_type {
372            "consume_notes" => {
373                if self.note_ids_hex.is_empty() {
374                    return Err(MultisigError::InvalidConfig(
375                        "consume_notes proposal requires metadata.note_ids".to_string(),
376                    ));
377                }
378                Ok(TransactionType::ConsumeNotes {
379                    note_ids: self.note_ids()?,
380                    metadata_version: self.consume_notes_metadata_version,
381                    notes: self.consume_notes_notes.clone(),
382                })
383            }
384            "p2id" => {
385                let recipient_str = self.recipient_hex.as_ref().ok_or_else(|| {
386                    MultisigError::InvalidConfig(
387                        "p2id proposal requires metadata.recipient_id".to_string(),
388                    )
389                })?;
390                let faucet_str = self.faucet_id_hex.as_ref().ok_or_else(|| {
391                    MultisigError::InvalidConfig(
392                        "p2id proposal requires metadata.faucet_id".to_string(),
393                    )
394                })?;
395                let parsed_amount = self.amount.ok_or_else(|| {
396                    MultisigError::InvalidConfig(
397                        "p2id proposal requires metadata.amount".to_string(),
398                    )
399                })?;
400                let recipient = AccountId::from_hex(recipient_str).map_err(|e| {
401                    MultisigError::InvalidConfig(format!("invalid recipient: {}", e))
402                })?;
403                let faucet_id = AccountId::from_hex(faucet_str).map_err(|e| {
404                    MultisigError::InvalidConfig(format!("invalid faucet_id: {}", e))
405                })?;
406                Ok(TransactionType::P2ID {
407                    recipient,
408                    faucet_id,
409                    amount: parsed_amount,
410                    note_type: self.p2id_note_type()?,
411                })
412            }
413            "switch_guardian" => {
414                let pubkey_hex = self.new_guardian_pubkey_hex.as_ref().ok_or_else(|| {
415                    MultisigError::InvalidConfig(
416                        "switch_guardian proposal requires metadata.new_guardian_pubkey"
417                            .to_string(),
418                    )
419                })?;
420                let endpoint = self.new_guardian_endpoint.as_ref().ok_or_else(|| {
421                    MultisigError::InvalidConfig(
422                        "switch_guardian proposal requires metadata.new_guardian_endpoint"
423                            .to_string(),
424                    )
425                })?;
426                let new_commitment =
427                    word_from_hex(pubkey_hex).map_err(MultisigError::InvalidConfig)?;
428                Ok(TransactionType::SwitchGuardian {
429                    new_endpoint: endpoint.clone(),
430                    new_commitment,
431                })
432            }
433            "update_procedure_threshold" => {
434                let threshold = self.new_threshold.ok_or_else(|| {
435                    MultisigError::InvalidConfig(
436                        "update_procedure_threshold proposal requires metadata.target_threshold"
437                            .to_string(),
438                    )
439                })?;
440                let procedure_name = self.target_procedure.as_ref().ok_or_else(|| {
441                    MultisigError::InvalidConfig(
442                        "update_procedure_threshold proposal requires metadata.target_procedure"
443                            .to_string(),
444                    )
445                })?;
446                let procedure = procedure_name
447                    .parse()
448                    .map_err(MultisigError::InvalidConfig)?;
449                Ok(TransactionType::UpdateProcedureThreshold {
450                    procedure,
451                    new_threshold: threshold as u32,
452                })
453            }
454            "add_signer" => {
455                let threshold = self.new_threshold.ok_or_else(|| {
456                    MultisigError::InvalidConfig(
457                        "add_signer proposal requires metadata.target_threshold".to_string(),
458                    )
459                })?;
460                let proposed_signers = self.signer_commitments()?;
461                if proposed_signers.is_empty() {
462                    return Err(MultisigError::InvalidConfig(
463                        "add_signer proposal requires metadata.signer_commitments".to_string(),
464                    ));
465                }
466                Ok(TransactionType::UpdateSigners {
467                    new_threshold: threshold as u32,
468                    signer_commitments: proposed_signers,
469                })
470            }
471            "remove_signer" => {
472                let threshold = self.new_threshold.ok_or_else(|| {
473                    MultisigError::InvalidConfig(
474                        "remove_signer proposal requires metadata.target_threshold".to_string(),
475                    )
476                })?;
477                let proposed_signers = self.signer_commitments()?;
478                if proposed_signers.is_empty() {
479                    return Err(MultisigError::InvalidConfig(
480                        "remove_signer proposal requires metadata.signer_commitments".to_string(),
481                    ));
482                }
483                Ok(TransactionType::UpdateSigners {
484                    new_threshold: threshold as u32,
485                    signer_commitments: proposed_signers,
486                })
487            }
488            "change_threshold" => {
489                let threshold = self.new_threshold.ok_or_else(|| {
490                    MultisigError::InvalidConfig(
491                        "change_threshold proposal requires metadata.target_threshold".to_string(),
492                    )
493                })?;
494                let proposed_signers = self.signer_commitments()?;
495                if proposed_signers.is_empty() {
496                    return Err(MultisigError::InvalidConfig(
497                        "change_threshold proposal requires metadata.signer_commitments"
498                            .to_string(),
499                    ));
500                }
501                Ok(TransactionType::UpdateSigners {
502                    new_threshold: threshold as u32,
503                    signer_commitments: proposed_signers,
504                })
505            }
506            _ => Ok(TransactionType::Custom),
507        }
508    }
509}
510
511/// A proposal signature entry.
512#[derive(Debug, Clone)]
513pub struct ProposalSignatureEntry {
514    pub signer_commitment: String,
515    pub signature_hex: String,
516    pub scheme: SignatureScheme,
517    pub public_key_hex: Option<String>,
518}
519
520impl ProposalSignatureEntry {
521    fn validate(&self) -> Result<()> {
522        word_from_hex(&self.signer_commitment).map_err(MultisigError::InvalidConfig)?;
523
524        let signature_hex = ensure_hex_prefix(&self.signature_hex);
525        match self.scheme {
526            SignatureScheme::Falcon => {
527                Poseidon2FalconSignature::from_hex(&signature_hex).map_err(|e| {
528                    MultisigError::Signature(format!("invalid proposal signature: {}", e))
529                })?;
530            }
531            SignatureScheme::Ecdsa => {
532                let signature_bytes =
533                    hex::decode(signature_hex.trim_start_matches("0x")).map_err(|e| {
534                        MultisigError::Signature(format!("invalid ECDSA signature hex: {}", e))
535                    })?;
536                EcdsaSignature::read_from_bytes(&signature_bytes).map_err(|e| {
537                    MultisigError::Signature(format!(
538                        "invalid ECDSA proposal signature bytes: {}",
539                        e
540                    ))
541                })?;
542
543                let public_key_hex = self.public_key_hex.as_ref().ok_or_else(|| {
544                    MultisigError::Signature(
545                        "ECDSA proposal signatures require a public key".to_string(),
546                    )
547                })?;
548                let public_key_bytes = hex::decode(public_key_hex.trim_start_matches("0x"))
549                    .map_err(|e| {
550                        MultisigError::Signature(format!("invalid ECDSA public key hex: {}", e))
551                    })?;
552                EcdsaPublicKey::read_from_bytes(&public_key_bytes).map_err(|e| {
553                    MultisigError::Signature(format!(
554                        "invalid ECDSA proposal public key bytes: {}",
555                        e
556                    ))
557                })?;
558            }
559        }
560
561        Ok(())
562    }
563}
564
565/// A proposal for a multisig transaction.
566#[derive(Debug, Clone)]
567pub struct Proposal {
568    pub id: String,
569    pub nonce: u64,
570    pub transaction_type: TransactionType,
571    pub status: ProposalStatus,
572    pub tx_summary: TransactionSummary,
573    pub signatures: Vec<ProposalSignatureEntry>,
574    pub metadata: ProposalMetadata,
575}
576
577impl Proposal {
578    pub fn from(delta: &DeltaObject) -> Result<Self> {
579        let payload: ProposalPayload = serde_json::from_str(&delta.delta_payload)?;
580
581        let tx_summary = TransactionSummary::from_json(&payload.tx_summary).map_err(|e| {
582            MultisigError::MidenClient(format!("failed to parse tx_summary: {}", e))
583        })?;
584
585        let metadata_payload = payload.metadata.clone().ok_or_else(|| {
586            MultisigError::InvalidConfig("proposal is missing metadata".to_string())
587        })?;
588        let proposal_type = metadata_payload.proposal_type.clone();
589        let required_signatures = metadata_payload.required_signatures.ok_or_else(|| {
590            MultisigError::InvalidConfig(
591                "proposal metadata.required_signatures is required".to_string(),
592            )
593        })?;
594        let required_signatures: usize = usize::try_from(required_signatures).map_err(|_| {
595            MultisigError::InvalidConfig(
596                "proposal metadata.required_signatures exceeds platform limits".to_string(),
597            )
598        })?;
599
600        let new_threshold = metadata_payload.target_threshold;
601        let signer_commitments_hex = metadata_payload.signer_commitments;
602        let salt_hex = metadata_payload.salt;
603        let recipient_hex = metadata_payload.recipient_id;
604        let faucet_id_hex = metadata_payload.faucet_id;
605        let amount = metadata_payload.amount.as_deref().map(|value| {
606            value.parse::<u64>().map_err(|e| {
607                MultisigError::InvalidConfig(format!(
608                    "invalid metadata.amount value '{}': {}",
609                    value, e
610                ))
611            })
612        });
613        let amount = match amount {
614            Some(parsed) => Some(parsed?),
615            None => None,
616        };
617        let note_type = metadata_payload.note_type;
618        let note_ids_hex = metadata_payload.note_ids;
619        let consume_notes_metadata_version = metadata_payload.consume_notes_metadata_version;
620        let consume_notes_notes = metadata_payload
621            .consume_notes_notes
622            .into_iter()
623            .map(SerializedNote::from_base64)
624            .collect();
625
626        let new_guardian_pubkey_hex = metadata_payload.new_guardian_pubkey;
627        let new_guardian_endpoint = metadata_payload.new_guardian_endpoint;
628        let target_procedure = metadata_payload.target_procedure;
629
630        let mut metadata = ProposalMetadata {
631            tx_summary_json: Some(payload.tx_summary.clone()),
632            proposal_type: Some(proposal_type.clone()),
633            new_threshold,
634            signer_commitments_hex: signer_commitments_hex.clone(),
635            salt_hex,
636            recipient_hex: recipient_hex.clone(),
637            faucet_id_hex: faucet_id_hex.clone(),
638            amount,
639            note_type,
640            note_ids_hex: note_ids_hex.clone(),
641            consume_notes_metadata_version,
642            consume_notes_notes,
643            new_guardian_pubkey_hex: new_guardian_pubkey_hex.clone(),
644            new_guardian_endpoint: new_guardian_endpoint.clone(),
645            target_procedure: target_procedure.clone(),
646            required_signatures: Some(required_signatures),
647            signers: Vec::new(),
648        };
649        let transaction_type = metadata.to_transaction_type(&proposal_type)?;
650
651        let mut seen_signers = HashSet::new();
652        let mut signatures = Vec::with_capacity(payload.signatures.len());
653        for signature in &payload.signatures {
654            let (scheme, signature_hex, public_key_hex) = match &signature.signature {
655                ProposalSignature::Falcon { signature } => {
656                    (SignatureScheme::Falcon, signature.clone(), None)
657                }
658                ProposalSignature::Ecdsa {
659                    signature,
660                    public_key,
661                } => (
662                    SignatureScheme::Ecdsa,
663                    signature.clone(),
664                    public_key.clone(),
665                ),
666            };
667
668            let entry = ProposalSignatureEntry {
669                signer_commitment: signature.signer_id.clone(),
670                signature_hex,
671                scheme,
672                public_key_hex,
673            };
674            entry.validate()?;
675
676            if !seen_signers.insert(entry.signer_commitment.to_lowercase()) {
677                return Err(MultisigError::InvalidConfig(format!(
678                    "duplicate proposal signature for signer {}",
679                    entry.signer_commitment
680                )));
681            }
682
683            metadata.signers.push(entry.signer_commitment.clone());
684            signatures.push(entry);
685        }
686
687        let commitment = tx_summary.to_commitment();
688        let id = format!("0x{}", hex::encode(word_to_bytes(&commitment)));
689
690        let mut proposal = Proposal {
691            id,
692            nonce: delta.nonce,
693            transaction_type,
694            status: ProposalStatus::Pending,
695            tx_summary,
696            signatures,
697            metadata,
698        };
699        proposal.refresh_status();
700        Ok(proposal)
701    }
702
703    /// Creates a new Proposal
704    pub fn new(
705        tx_summary: TransactionSummary,
706        nonce: u64,
707        transaction_type: TransactionType,
708        mut metadata: ProposalMetadata,
709    ) -> Self {
710        let commitment = tx_summary.to_commitment();
711        let id = format!("0x{}", hex::encode(word_to_bytes(&commitment)));
712
713        let signatures_required = metadata
714            .required_signatures
715            .unwrap_or(metadata.signer_commitments_hex.len());
716        metadata
717            .required_signatures
718            .get_or_insert(signatures_required);
719        if metadata.proposal_type.is_none() {
720            metadata.proposal_type = transaction_type.proposal_type().map(str::to_string);
721        }
722
723        let mut proposal = Self {
724            id,
725            nonce,
726            transaction_type,
727            status: ProposalStatus::Pending,
728            tx_summary,
729            signatures: Vec::new(),
730            metadata,
731        };
732        proposal.refresh_status();
733        proposal
734    }
735
736    pub fn has_signed(&self, signer_commitment_hex: &str) -> bool {
737        self.metadata
738            .signers
739            .iter()
740            .any(|s| s.eq_ignore_ascii_case(signer_commitment_hex))
741    }
742
743    pub fn signatures_collected(&self) -> usize {
744        self.metadata.signers.len()
745    }
746
747    pub fn signatures_required(&self) -> usize {
748        self.metadata
749            .required_signatures
750            .unwrap_or(self.metadata.signer_commitments_hex.len())
751    }
752
753    pub fn signature_counts(&self) -> (usize, usize) {
754        (self.signatures_collected(), self.signatures_required())
755    }
756
757    pub fn signatures_needed(&self) -> usize {
758        self.signatures_required()
759            .saturating_sub(self.signatures_collected())
760    }
761
762    /// Returns the commitment hex strings of signers who haven't signed yet.
763    pub fn missing_signers(&self) -> Vec<String> {
764        if !self.status.is_pending() {
765            return Vec::new();
766        }
767
768        let signed: HashSet<_> = self
769            .metadata
770            .signers
771            .iter()
772            .map(|s| s.to_lowercase())
773            .collect();
774
775        self.metadata
776            .signer_commitments_hex
777            .iter()
778            .filter(|c| !signed.contains(&c.to_lowercase()))
779            .cloned()
780            .collect()
781    }
782
783    fn refresh_status(&mut self) {
784        let signatures_required = self.signatures_required();
785        self.status =
786            if self.metadata.signers.len() >= signatures_required && signatures_required > 0 {
787                ProposalStatus::Ready
788            } else {
789                ProposalStatus::Pending
790            };
791    }
792}
793/// Converts a Word to bytes.
794fn word_to_bytes(word: &Word) -> Vec<u8> {
795    word.iter()
796        .flat_map(|felt| felt.as_canonical_u64().to_le_bytes())
797        .collect()
798}
799
800#[cfg(test)]
801mod tests {
802    use super::*;
803    use miden_protocol::account::delta::{AccountDelta, AccountStorageDelta, AccountVaultDelta};
804    use miden_protocol::transaction::{InputNotes, RawOutputNotes};
805
806    fn create_test_tx_summary() -> TransactionSummary {
807        // Use a minimal valid account ID
808        let account_id = AccountId::from_hex("0x7b7b7b7a7b7b7b017b7b7b7b7b7b7b").unwrap();
809        let delta = AccountDelta::new(
810            account_id,
811            AccountStorageDelta::default(),
812            AccountVaultDelta::default(),
813            Felt::ZERO,
814        )
815        .expect("Valid empty delta");
816
817        TransactionSummary::new(
818            delta,
819            InputNotes::new(Vec::new()).unwrap(),
820            RawOutputNotes::new(Vec::new()).unwrap(),
821            Word::default(),
822        )
823    }
824
825    #[test]
826    fn test_word_from_hex_roundtrip() {
827        let original = "0x0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20";
828        let word = word_from_hex(original).expect("hex should decode");
829        let bytes = word_to_bytes(&word);
830        let result = format!("0x{}", hex::encode(bytes));
831        assert_eq!(original, result);
832    }
833
834    #[test]
835    fn test_word_from_hex_rejects_non_canonical_field_element() {
836        let invalid = format!("0x{}{}", "ff".repeat(8), "00".repeat(24));
837        let err = word_from_hex(&invalid).expect_err("non-canonical field element should fail");
838        assert!(err.contains("invalid field element"));
839    }
840
841    #[test]
842    fn test_proposal_status_checks() {
843        let pending = ProposalStatus::Pending;
844        assert!(pending.is_pending());
845        assert!(!pending.is_ready());
846
847        let ready = ProposalStatus::Ready;
848        assert!(ready.is_ready());
849        assert!(!ready.is_pending());
850    }
851
852    #[test]
853    fn test_transaction_type_transfer() {
854        // Use valid Miden AccountId format
855        let recipient = AccountId::from_hex("0x7b7b7b7a7b7b7b017b7b7b7b7b7b7b").unwrap();
856        let faucet_id = AccountId::from_hex("0x7c7c7c7c7c7c7c017c7c7c7c7c7c7c").unwrap();
857        let amount = 1000u64;
858
859        let tx = TransactionType::transfer(recipient, faucet_id, amount);
860
861        assert_eq!(
862            tx,
863            TransactionType::P2ID {
864                recipient,
865                faucet_id,
866                amount,
867                note_type: NoteType::Public,
868            }
869        );
870    }
871
872    #[test]
873    fn test_transaction_type_consume_notes() {
874        let note_id = NoteId::from_raw(Word::default());
875        let tx = TransactionType::consume_notes(vec![note_id]);
876
877        assert_eq!(
878            tx,
879            TransactionType::ConsumeNotes {
880                note_ids: vec![note_id],
881                metadata_version: None,
882                notes: Vec::new(),
883            }
884        );
885    }
886
887    #[test]
888    fn test_transaction_type_add_cosigner() {
889        let commitment = Word::default();
890        let tx = TransactionType::add_cosigner(commitment);
891
892        assert_eq!(
893            tx,
894            TransactionType::AddCosigner {
895                new_commitment: commitment
896            }
897        );
898    }
899
900    #[test]
901    fn test_transaction_type_remove_cosigner() {
902        let commitment = Word::default();
903        let tx = TransactionType::remove_cosigner(commitment);
904
905        assert_eq!(tx, TransactionType::RemoveCosigner { commitment });
906    }
907
908    #[test]
909    fn test_transaction_type_switch_guardian() {
910        let endpoint = "http://new-guardian.example.com";
911        let commitment = Word::default();
912
913        let tx = TransactionType::switch_guardian(endpoint, commitment);
914
915        assert_eq!(
916            tx,
917            TransactionType::SwitchGuardian {
918                new_endpoint: endpoint.to_string(),
919                new_commitment: commitment
920            }
921        );
922    }
923
924    #[test]
925    fn test_transaction_type_switch_guardian_rejects_non_canonical_commitment() {
926        let metadata = ProposalMetadata {
927            new_guardian_pubkey_hex: Some(format!("0x{}{}", "ff".repeat(8), "00".repeat(24))),
928            new_guardian_endpoint: Some("http://new-guardian.example.com".to_string()),
929            ..Default::default()
930        };
931
932        let err = metadata
933            .to_transaction_type("switch_guardian")
934            .expect_err("non-canonical GUARDIAN commitment should be rejected");
935        assert!(err.to_string().contains("invalid field element"));
936    }
937
938    #[test]
939    fn test_transaction_type_update_signers() {
940        let threshold = 2u32;
941        let signers = vec![Word::default()];
942
943        let tx = TransactionType::update_signers(threshold, signers.clone());
944
945        assert_eq!(
946            tx,
947            TransactionType::UpdateSigners {
948                new_threshold: threshold,
949                signer_commitments: signers
950            }
951        );
952    }
953
954    #[test]
955    fn test_transaction_type_requires_guardian_ack() {
956        let recipient = AccountId::from_hex("0x7b7b7b7a7b7b7b017b7b7b7b7b7b7b").unwrap();
957        let faucet_id = AccountId::from_hex("0x7c7c7c7c7c7c7c017c7c7c7c7c7c7c").unwrap();
958
959        assert!(TransactionType::transfer(recipient, faucet_id, 1).requires_guardian_ack());
960        assert!(
961            !TransactionType::switch_guardian("http://new-guardian.example.com", Word::default())
962                .requires_guardian_ack()
963        );
964    }
965
966    #[test]
967    fn test_transaction_type_supports_offline_execution() {
968        let note_id = NoteId::from_raw(Word::default());
969        assert!(!TransactionType::consume_notes(vec![note_id]).supports_offline_execution());
970        assert!(
971            TransactionType::switch_guardian("http://new-guardian.example.com", Word::default())
972                .supports_offline_execution()
973        );
974    }
975
976    #[test]
977    fn test_proposal_signature_counts() {
978        let pending = ProposalStatus::Pending;
979
980        let proposal = Proposal {
981            id: "0x123".to_string(),
982            nonce: 1,
983            transaction_type: TransactionType::add_cosigner(Word::default()),
984            status: pending,
985            tx_summary: create_test_tx_summary(),
986            signatures: Vec::new(),
987            metadata: ProposalMetadata {
988                required_signatures: Some(3),
989                signers: vec!["0xabc".to_string()],
990                signer_commitments_hex: vec![
991                    "0xabc".to_string(),
992                    "0xdef".to_string(),
993                    "0x123".to_string(),
994                ],
995                ..Default::default()
996            },
997        };
998
999        assert_eq!(proposal.signature_counts(), (1, 3));
1000        assert_eq!(proposal.signatures_needed(), 2);
1001    }
1002
1003    #[test]
1004    fn test_proposal_missing_signers() {
1005        let pending = ProposalStatus::Pending;
1006
1007        let proposal = Proposal {
1008            id: "0x123".to_string(),
1009            nonce: 1,
1010            transaction_type: TransactionType::add_cosigner(Word::default()),
1011            status: pending,
1012            tx_summary: create_test_tx_summary(),
1013            signatures: Vec::new(),
1014            metadata: ProposalMetadata {
1015                signers: vec!["0xABC".to_string()], // uppercase to test case-insensitivity
1016                signer_commitments_hex: vec![
1017                    "0xabc".to_string(), // lowercase
1018                    "0xdef".to_string(),
1019                    "0x456".to_string(),
1020                ],
1021                ..Default::default()
1022            },
1023        };
1024
1025        let missing = proposal.missing_signers();
1026        assert_eq!(missing.len(), 2);
1027        assert!(missing.contains(&"0xdef".to_string()));
1028        assert!(missing.contains(&"0x456".to_string()));
1029        // 0xabc should NOT be in missing (already signed)
1030        assert!(!missing.contains(&"0xabc".to_string()));
1031    }
1032
1033    #[test]
1034    fn test_proposal_signatures_needed_when_ready() {
1035        let ready = ProposalStatus::Ready;
1036
1037        let proposal = Proposal {
1038            id: "0x123".to_string(),
1039            nonce: 1,
1040            transaction_type: TransactionType::add_cosigner(Word::default()),
1041            status: ready,
1042            tx_summary: create_test_tx_summary(),
1043            signatures: Vec::new(),
1044            metadata: ProposalMetadata {
1045                required_signatures: Some(2),
1046                signers: vec!["0xabc".to_string(), "0xdef".to_string()],
1047                ..Default::default()
1048            },
1049        };
1050
1051        assert_eq!(proposal.signatures_needed(), 0);
1052    }
1053
1054    // ==================== US1 dispatch wiring tests (issue #229) ====================
1055
1056    /// The v1 `consume_notes` constructor builds a TransactionType that
1057    /// the dispatch will route through the legacy local-store path.
1058    #[test]
1059    fn transaction_type_consume_notes_legacy_constructor_marks_v1() {
1060        let note_id = NoteId::from_raw(Word::default());
1061        let tx = TransactionType::consume_notes(vec![note_id]);
1062        match tx {
1063            TransactionType::ConsumeNotes {
1064                metadata_version,
1065                notes,
1066                ..
1067            } => {
1068                assert!(
1069                    metadata_version.is_none(),
1070                    "legacy constructor must not stamp a version"
1071                );
1072                assert!(notes.is_empty(), "legacy constructor must not embed notes");
1073            }
1074            other => panic!("expected ConsumeNotes, got {:?}", other),
1075        }
1076    }
1077
1078    /// The v2 `consume_notes_v2` constructor stamps the discriminator
1079    /// and carries the embedded notes so the dispatch routes through
1080    /// the self-contained rebuild path.
1081    #[test]
1082    fn transaction_type_consume_notes_v2_constructor_stamps_version_and_notes() {
1083        let note_id = NoteId::from_raw(Word::default());
1084        let embedded = vec![SerializedNote::from_base64("YmFzZTY0Tm90ZQ==".to_string())];
1085        let tx = TransactionType::consume_notes_v2(vec![note_id], embedded.clone());
1086        match tx {
1087            TransactionType::ConsumeNotes {
1088                metadata_version,
1089                notes,
1090                ..
1091            } => {
1092                assert_eq!(metadata_version, Some(CONSUME_NOTES_METADATA_VERSION_V2));
1093                assert_eq!(notes, embedded);
1094            }
1095            other => panic!("expected ConsumeNotes, got {:?}", other),
1096        }
1097    }
1098
1099    /// `ProposalMetadata::to_transaction_type` threads the v2 discriminator
1100    /// and embedded notes from wire metadata into the runtime
1101    /// TransactionType so the dispatch in `execution.rs` sees them.
1102    /// This is the wire→runtime bridge that the foundational tests in
1103    /// `payload.rs` exercise at the JSON layer.
1104    #[test]
1105    fn to_transaction_type_threads_v2_metadata() {
1106        let note_id_hex =
1107            "0x0100000000000000000000000000000000000000000000000000000000000000".to_string();
1108        let metadata = ProposalMetadata {
1109            note_ids_hex: vec![note_id_hex],
1110            consume_notes_metadata_version: Some(CONSUME_NOTES_METADATA_VERSION_V2),
1111            consume_notes_notes: vec![SerializedNote::from_base64("YmFzZTY0Tm90ZQ==".to_string())],
1112            ..Default::default()
1113        };
1114
1115        let tx = metadata
1116            .to_transaction_type("consume_notes")
1117            .expect("to_transaction_type");
1118
1119        match tx {
1120            TransactionType::ConsumeNotes {
1121                metadata_version,
1122                notes,
1123                ..
1124            } => {
1125                assert_eq!(metadata_version, Some(CONSUME_NOTES_METADATA_VERSION_V2));
1126                assert_eq!(notes.len(), 1);
1127                assert_eq!(notes[0].as_str(), "YmFzZTY0Tm90ZQ==");
1128            }
1129            other => panic!("expected ConsumeNotes, got {:?}", other),
1130        }
1131    }
1132
1133    // ==================== SerializedNote tests (issue #229) ====================
1134
1135    /// `SerializedNote` serializes transparently as a string (the base64
1136    /// of a Miden `Note`'s byte serialization). This is the wire format
1137    /// consumed by the v2 dispatch in US1.
1138    #[test]
1139    fn serialized_note_is_transparent_string_on_wire() {
1140        let sn = SerializedNote::from_base64("YmFzZTY0LWVuY29kZWQtbm90ZQ==".to_string());
1141        let json = serde_json::to_value(&sn).unwrap();
1142        assert_eq!(
1143            json,
1144            serde_json::Value::String("YmFzZTY0LWVuY29kZWQtbm90ZQ==".to_string())
1145        );
1146
1147        let parsed: SerializedNote =
1148            serde_json::from_str(r#""YmFzZTY0LWVuY29kZWQtbm90ZQ==""#).unwrap();
1149        assert_eq!(parsed.0, sn.0);
1150    }
1151
1152    /// Decoding garbage base64 yields a clean error, not a panic.
1153    #[test]
1154    fn serialized_note_rejects_invalid_base64() {
1155        let sn = SerializedNote::from_base64("@@@not-base64@@@".to_string());
1156        let err = sn.to_note().unwrap_err();
1157        assert!(matches!(err, MultisigError::InvalidConfig(_)));
1158    }
1159
1160    /// `MAX_CONSUME_NOTES_METADATA_BYTES` is the 256 KiB limit from
1161    /// research.md Decision 4 — pinning the value so future changes
1162    /// are visible in test diffs.
1163    #[test]
1164    fn max_consume_notes_metadata_bytes_is_256_kib() {
1165        assert_eq!(MAX_CONSUME_NOTES_METADATA_BYTES, 256 * 1024);
1166        assert_eq!(MAX_CONSUME_NOTES_METADATA_BYTES, 262_144);
1167    }
1168
1169    /// v1/v2 discriminator helpers on the internal `ProposalMetadata`
1170    /// struct: absence and `Some(1)` both signal v1; `Some(2)` is v2.
1171    #[test]
1172    fn proposal_metadata_consume_notes_version_helpers() {
1173        let v1_absent = ProposalMetadata::default();
1174        assert!(v1_absent.is_consume_notes_v1());
1175        assert!(!v1_absent.is_consume_notes_v2());
1176
1177        let v1_explicit = ProposalMetadata {
1178            consume_notes_metadata_version: Some(1),
1179            ..Default::default()
1180        };
1181        assert!(v1_explicit.is_consume_notes_v1());
1182        assert!(!v1_explicit.is_consume_notes_v2());
1183
1184        let v2 = ProposalMetadata {
1185            consume_notes_metadata_version: Some(CONSUME_NOTES_METADATA_VERSION_V2),
1186            ..Default::default()
1187        };
1188        assert!(v2.is_consume_notes_v2());
1189        assert!(!v2.is_consume_notes_v1());
1190
1191        // Unknown future version is neither v1 nor v2; dispatch handles
1192        // rejection per spec FR-009.
1193        let unknown = ProposalMetadata {
1194            consume_notes_metadata_version: Some(99),
1195            ..Default::default()
1196        };
1197        assert!(!unknown.is_consume_notes_v1());
1198        assert!(!unknown.is_consume_notes_v2());
1199    }
1200
1201    // ==================== ProposalMetadata parser tests ====================
1202
1203    #[test]
1204    fn test_metadata_salt_valid() {
1205        let metadata = ProposalMetadata {
1206            salt_hex: Some(
1207                "0x0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20".to_string(),
1208            ),
1209            ..Default::default()
1210        };
1211
1212        let salt = metadata.salt().expect("salt should parse");
1213        // Verify it's not the default Word
1214        assert_ne!(salt, Word::default());
1215    }
1216
1217    #[test]
1218    fn test_metadata_salt_rejects_non_canonical_field_element() {
1219        let metadata = ProposalMetadata {
1220            salt_hex: Some(format!("0x{}{}", "ff".repeat(8), "00".repeat(24))),
1221            ..Default::default()
1222        };
1223
1224        let err = metadata
1225            .salt()
1226            .expect_err("non-canonical salt should be rejected");
1227        assert!(err.to_string().contains("invalid field element"));
1228    }
1229
1230    #[test]
1231    fn test_metadata_salt_none_returns_default() {
1232        let metadata = ProposalMetadata::default();
1233
1234        let salt = metadata.salt().expect("salt should return default");
1235        assert_eq!(salt, Word::default());
1236    }
1237
1238    #[test]
1239    fn test_metadata_signer_commitments_valid() {
1240        let hex1 = "0x0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20";
1241        let hex2 = "0x2122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f40";
1242
1243        let metadata = ProposalMetadata {
1244            signer_commitments_hex: vec![hex1.to_string(), hex2.to_string()],
1245            ..Default::default()
1246        };
1247
1248        let commitments = metadata.signer_commitments().expect("should parse");
1249        assert_eq!(commitments.len(), 2);
1250    }
1251
1252    #[test]
1253    fn test_metadata_signer_commitments_invalid_hex() {
1254        let metadata = ProposalMetadata {
1255            signer_commitments_hex: vec!["not_valid_hex".to_string()],
1256            ..Default::default()
1257        };
1258
1259        assert!(metadata.signer_commitments().is_err());
1260    }
1261
1262    #[test]
1263    fn test_metadata_note_ids_valid() {
1264        // NoteId is 32 bytes = 64 hex chars
1265        let note_hex = "0x0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20";
1266
1267        let metadata = ProposalMetadata {
1268            note_ids_hex: vec![note_hex.to_string()],
1269            ..Default::default()
1270        };
1271
1272        let note_ids = metadata.note_ids().expect("should parse");
1273        assert_eq!(note_ids.len(), 1);
1274    }
1275
1276    #[test]
1277    fn to_transaction_type_maps_unmodeled_label_to_custom() {
1278        let metadata = ProposalMetadata::default();
1279
1280        let tx_type = metadata
1281            .to_transaction_type("b2agg")
1282            .expect("unmodeled proposal type should map to Custom");
1283
1284        assert_eq!(tx_type, TransactionType::Custom);
1285        assert_eq!(tx_type.type_name(), "Custom");
1286        assert_eq!(tx_type.proposal_type(), None);
1287    }
1288
1289    #[test]
1290    fn to_transaction_type_still_rejects_empty_label() {
1291        let metadata = ProposalMetadata::default();
1292
1293        let err = metadata
1294            .to_transaction_type("")
1295            .expect_err("empty proposal type must be rejected");
1296        assert!(err.to_string().contains("proposal_type is required"));
1297    }
1298
1299    // ---------- p2id note_type (issue #322) ----------
1300
1301    fn p2id_metadata(note_type: Option<&str>) -> ProposalMetadata {
1302        ProposalMetadata {
1303            recipient_hex: Some("0x7b7b7b7a7b7b7b017b7b7b7b7b7b7b".to_string()),
1304            faucet_id_hex: Some("0x7c7c7c7c7c7c7c017c7c7c7c7c7c7c".to_string()),
1305            amount: Some(1000),
1306            note_type: note_type.map(str::to_string),
1307            ..Default::default()
1308        }
1309    }
1310
1311    /// Absent `note_type` must keep mapping to a public note — the only
1312    /// behavior that existed before the field, so pre-#322 proposals
1313    /// rebuild identically.
1314    #[test]
1315    fn to_transaction_type_p2id_defaults_to_public_note() {
1316        let tx_type = p2id_metadata(None)
1317            .to_transaction_type("p2id")
1318            .expect("to_transaction_type");
1319        assert!(matches!(
1320            tx_type,
1321            TransactionType::P2ID {
1322                note_type: NoteType::Public,
1323                ..
1324            }
1325        ));
1326    }
1327
1328    #[test]
1329    fn to_transaction_type_p2id_threads_private_note_type() {
1330        let tx_type = p2id_metadata(Some("private"))
1331            .to_transaction_type("p2id")
1332            .expect("to_transaction_type");
1333        assert!(matches!(
1334            tx_type,
1335            TransactionType::P2ID {
1336                note_type: NoteType::Private,
1337                ..
1338            }
1339        ));
1340    }
1341
1342    /// An unknown `note_type` must be rejected, not silently rebuilt as a
1343    /// public note that could never match the signed tx_summary commitment.
1344    #[test]
1345    fn to_transaction_type_p2id_rejects_unknown_note_type() {
1346        let err = p2id_metadata(Some("encrypted"))
1347            .to_transaction_type("p2id")
1348            .expect_err("unknown note_type must be rejected");
1349        assert!(err.to_string().contains("unsupported metadata.note_type"));
1350    }
1351
1352    #[test]
1353    fn builtin_proposal_types_are_recognized() {
1354        for label in [
1355            "add_signer",
1356            "remove_signer",
1357            "change_threshold",
1358            "update_procedure_threshold",
1359            "switch_guardian",
1360            "consume_notes",
1361            "p2id",
1362            "custom",
1363        ] {
1364            assert!(
1365                is_builtin_proposal_type(label),
1366                "{label} should be reserved"
1367            );
1368        }
1369    }
1370
1371    #[test]
1372    fn custom_labels_are_not_builtin() {
1373        assert!(!is_builtin_proposal_type("b2agg"));
1374        assert!(!is_builtin_proposal_type(""));
1375        assert!(!is_builtin_proposal_type("P2ID"));
1376    }
1377}