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};
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 },
98 ConsumeNotes {
99 note_ids: Vec<NoteId>,
100 metadata_version: Option<u32>,
102 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 Custom,
130}
131
132impl TransactionType {
133 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 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 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 pub fn add_cosigner(new_commitment: Word) -> Self {
162 Self::AddCosigner { new_commitment }
163 }
164
165 pub fn remove_cosigner(commitment: Word) -> Self {
167 Self::RemoveCosigner { commitment }
168 }
169
170 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 pub fn update_procedure_threshold(procedure: ProcedureName, new_threshold: u32) -> Self {
180 Self::UpdateProcedureThreshold {
181 procedure,
182 new_threshold,
183 }
184 }
185
186 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 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 pub fn supports_offline_execution(&self) -> bool {
223 matches!(self, Self::SwitchGuardian { .. })
224 }
225
226 pub fn requires_guardian_ack(&self) -> bool {
228 !self.supports_offline_execution()
229 }
230}
231
232const 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 "custom",
246];
247
248pub(crate) fn is_builtin_proposal_type(proposal_type: &str) -> bool {
249 BUILTIN_PROPOSAL_TYPES.contains(&proposal_type)
250}
251
252#[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 pub consume_notes_metadata_version: Option<u32>,
270
271 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 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 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 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#[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#[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 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 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}
756fn 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 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 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()], signer_commitments_hex: vec![
979 "0xabc".to_string(), "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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 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 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}