1use 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
28pub const MAX_CONSUME_NOTES_METADATA_BYTES: usize = 256 * 1024;
30
31pub const CONSUME_NOTES_METADATA_VERSION_V2: u32 = 2;
33
34#[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 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#[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#[derive(Debug, Clone, PartialEq, Eq)]
91#[non_exhaustive]
92pub enum TransactionType {
93 P2ID {
94 recipient: AccountId,
95 faucet_id: AccountId,
96 amount: u64,
97 note_type: NoteType,
100 },
101 ConsumeNotes {
102 note_ids: Vec<NoteId>,
103 metadata_version: Option<u32>,
105 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 Custom,
133}
134
135impl TransactionType {
136 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 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 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 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 pub fn add_cosigner(new_commitment: Word) -> Self {
177 Self::AddCosigner { new_commitment }
178 }
179
180 pub fn remove_cosigner(commitment: Word) -> Self {
182 Self::RemoveCosigner { commitment }
183 }
184
185 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 pub fn update_procedure_threshold(procedure: ProcedureName, new_threshold: u32) -> Self {
195 Self::UpdateProcedureThreshold {
196 procedure,
197 new_threshold,
198 }
199 }
200
201 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 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 pub fn supports_offline_execution(&self) -> bool {
238 matches!(self, Self::SwitchGuardian { .. })
239 }
240
241 pub fn requires_guardian_ack(&self) -> bool {
243 !self.supports_offline_execution()
244 }
245}
246
247const 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 "custom",
261];
262
263pub(crate) fn is_builtin_proposal_type(proposal_type: &str) -> bool {
264 BUILTIN_PROPOSAL_TYPES.contains(&proposal_type)
265}
266
267#[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 pub note_type: Option<String>,
282
283 pub note_ids_hex: Vec<String>,
284
285 pub consume_notes_metadata_version: Option<u32>,
288
289 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 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 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 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 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#[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#[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 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 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}
793fn 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 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 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()], signer_commitments_hex: vec![
1017 "0xabc".to_string(), "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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 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 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 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 #[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 #[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}