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