1use std::collections::HashSet;
9
10use guardian_shared::FromJson;
11use guardian_shared::SignatureScheme;
12use guardian_shared::hex::FromHex;
13use miden_protocol::account::AccountId;
14use miden_protocol::crypto::dsa::ecdsa_k256_keccak::{
15 PublicKey as EcdsaPublicKey, Signature as EcdsaSignature,
16};
17use miden_protocol::crypto::dsa::falcon512_poseidon2::Signature as Poseidon2FalconSignature;
18use miden_protocol::transaction::TransactionSummary;
19use miden_protocol::utils::serde::Deserializable;
20use serde::{Deserialize, Serialize};
21
22use crate::error::{MultisigError, Result};
23use crate::keystore::{ensure_hex_prefix, word_from_hex};
24use crate::proposal::{
25 Proposal, ProposalMetadata, ProposalSignatureEntry, ProposalStatus, SerializedNote,
26};
27use crate::utils::hex_body_eq;
28
29pub const EXPORT_VERSION: u32 = 1;
31
32fn default_signature_scheme() -> SignatureScheme {
33 SignatureScheme::Falcon
34}
35
36#[derive(Serialize, Deserialize, Debug, Clone)]
38pub struct ExportedProposal {
39 pub version: u32,
40 pub account_id: String,
41
42 pub id: String,
43 pub nonce: u64,
44
45 pub tx_summary: serde_json::Value,
46
47 #[serde(default)]
48 pub signatures: Vec<ExportedSignature>,
49
50 pub signatures_required: usize,
51 pub metadata: ExportedMetadata,
52}
53
54#[derive(Serialize, Deserialize, Debug, Clone)]
56pub struct ExportedSignature {
57 pub signer_commitment: String,
58 pub signature: String,
59 #[serde(default = "default_signature_scheme")]
60 pub scheme: SignatureScheme,
61 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub public_key_hex: Option<String>,
63}
64
65#[derive(Serialize, Deserialize, Debug, Clone, Default)]
67pub struct ExportedMetadata {
68 #[serde(default)]
69 pub proposal_type: String,
70
71 #[serde(skip_serializing_if = "Option::is_none")]
72 pub salt_hex: Option<String>,
73
74 #[serde(skip_serializing_if = "Option::is_none")]
75 pub new_threshold: Option<u64>,
76
77 #[serde(default, skip_serializing_if = "Vec::is_empty")]
78 pub signer_commitments_hex: Vec<String>,
79
80 #[serde(skip_serializing_if = "Option::is_none")]
81 pub recipient_hex: Option<String>,
82
83 #[serde(skip_serializing_if = "Option::is_none")]
84 pub faucet_id_hex: Option<String>,
85
86 #[serde(skip_serializing_if = "Option::is_none")]
87 pub amount: Option<u64>,
88
89 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub note_type: Option<String>,
93
94 #[serde(default, skip_serializing_if = "Vec::is_empty")]
95 pub note_ids_hex: Vec<String>,
96
97 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub consume_notes_metadata_version: Option<u32>,
101
102 #[serde(default, skip_serializing_if = "Vec::is_empty")]
105 pub consume_notes_notes: Vec<String>,
106
107 #[serde(skip_serializing_if = "Option::is_none")]
108 pub new_guardian_pubkey_hex: Option<String>,
109
110 #[serde(skip_serializing_if = "Option::is_none")]
111 pub new_guardian_endpoint: Option<String>,
112
113 #[serde(skip_serializing_if = "Option::is_none")]
114 pub target_procedure: Option<String>,
115}
116
117impl ExportedProposal {
118 fn metadata(&self) -> ProposalMetadata {
119 ProposalMetadata {
120 tx_summary_json: Some(self.tx_summary.clone()),
121 proposal_type: Some(self.metadata.proposal_type.clone()),
122 new_threshold: self.metadata.new_threshold,
123 signer_commitments_hex: self.metadata.signer_commitments_hex.clone(),
124 salt_hex: self.metadata.salt_hex.clone(),
125 recipient_hex: self.metadata.recipient_hex.clone(),
126 faucet_id_hex: self.metadata.faucet_id_hex.clone(),
127 amount: self.metadata.amount,
128 note_type: self.metadata.note_type.clone(),
129 note_ids_hex: self.metadata.note_ids_hex.clone(),
130 consume_notes_metadata_version: self.metadata.consume_notes_metadata_version,
131 consume_notes_notes: self
132 .metadata
133 .consume_notes_notes
134 .iter()
135 .cloned()
136 .map(SerializedNote::from_base64)
137 .collect(),
138 new_guardian_pubkey_hex: self.metadata.new_guardian_pubkey_hex.clone(),
139 new_guardian_endpoint: self.metadata.new_guardian_endpoint.clone(),
140 target_procedure: self.metadata.target_procedure.clone(),
141 required_signatures: Some(self.signatures_required),
142 signers: self
143 .signatures
144 .iter()
145 .map(|signature| signature.signer_commitment.clone())
146 .collect(),
147 }
148 }
149
150 fn expected_id(tx_summary: &TransactionSummary) -> String {
151 format!(
152 "0x{}",
153 hex::encode(miden_protocol::utils::serde::Serializable::to_bytes(
154 &tx_summary.to_commitment(),
155 ))
156 )
157 }
158
159 fn validate_signatures(&self) -> Result<()> {
160 let mut seen_signers = HashSet::new();
161
162 for signature in &self.signatures {
163 word_from_hex(&signature.signer_commitment).map_err(MultisigError::InvalidConfig)?;
164
165 let signature_hex = ensure_hex_prefix(&signature.signature);
166 match signature.scheme {
167 SignatureScheme::Falcon => {
168 Poseidon2FalconSignature::from_hex(&signature_hex).map_err(|e| {
169 MultisigError::Signature(format!("invalid exported signature: {}", e))
170 })?;
171 }
172 SignatureScheme::Ecdsa => {
173 let signature_bytes = hex::decode(signature_hex.trim_start_matches("0x"))
174 .map_err(|e| {
175 MultisigError::Signature(format!(
176 "invalid ECDSA exported signature hex: {}",
177 e
178 ))
179 })?;
180 EcdsaSignature::read_from_bytes(&signature_bytes).map_err(|e| {
181 MultisigError::Signature(format!(
182 "invalid ECDSA exported signature bytes: {}",
183 e
184 ))
185 })?;
186 let public_key_hex = signature.public_key_hex.as_ref().ok_or_else(|| {
187 MultisigError::Signature(
188 "ECDSA exported signatures require a public key".to_string(),
189 )
190 })?;
191 let public_key_bytes = hex::decode(public_key_hex.trim_start_matches("0x"))
192 .map_err(|e| {
193 MultisigError::Signature(format!(
194 "invalid ECDSA exported public key hex: {}",
195 e
196 ))
197 })?;
198 EcdsaPublicKey::read_from_bytes(&public_key_bytes).map_err(|e| {
199 MultisigError::Signature(format!(
200 "invalid ECDSA exported public key bytes: {}",
201 e
202 ))
203 })?;
204 }
205 }
206
207 if !seen_signers.insert(signature.signer_commitment.to_lowercase()) {
208 return Err(MultisigError::InvalidConfig(format!(
209 "duplicate exported signature for signer {}",
210 signature.signer_commitment
211 )));
212 }
213 }
214
215 Ok(())
216 }
217
218 pub fn validate(&self, expected_account_id: Option<AccountId>) -> Result<()> {
219 let account_id = self.account_id()?;
220 if let Some(expected_account_id) = expected_account_id
221 && account_id != expected_account_id
222 {
223 return Err(MultisigError::InvalidConfig(format!(
224 "proposal account {} does not match loaded account {}",
225 self.account_id, expected_account_id
226 )));
227 }
228
229 if self.id.is_empty() {
230 return Err(MultisigError::InvalidConfig(
231 "proposal id is required".to_string(),
232 ));
233 }
234
235 if self.signatures_required == 0 {
236 return Err(MultisigError::InvalidConfig(
237 "signatures_required must be greater than 0".to_string(),
238 ));
239 }
240
241 let tx_summary = TransactionSummary::from_json(&self.tx_summary).map_err(|e| {
242 MultisigError::InvalidConfig(format!("failed to parse tx_summary: {}", e))
243 })?;
244 let expected_id = Self::expected_id(&tx_summary);
245 if !hex_body_eq(&self.id, &expected_id) {
246 return Err(MultisigError::InvalidConfig(format!(
247 "proposal id {} does not match tx_summary commitment {}",
248 self.id, expected_id
249 )));
250 }
251
252 let metadata = self.metadata();
253 metadata.to_transaction_type(&self.metadata.proposal_type)?;
254 self.validate_signatures()
255 }
256
257 pub fn from_proposal(proposal: &Proposal, account_id: AccountId) -> Result<Self> {
259 let proposal_type = proposal
260 .metadata
261 .proposal_type
262 .clone()
263 .or_else(|| {
264 proposal
265 .transaction_type
266 .proposal_type()
267 .map(str::to_string)
268 })
269 .ok_or_else(|| {
270 MultisigError::InvalidConfig(
271 "cannot export signer update proposal without metadata.proposal_type"
272 .to_string(),
273 )
274 })?;
275 let signatures_required = proposal.signatures_required();
276
277 let signatures = Vec::new();
278
279 let metadata = ExportedMetadata {
280 proposal_type,
281 salt_hex: proposal.metadata.salt_hex.clone(),
282 new_threshold: proposal.metadata.new_threshold,
283 signer_commitments_hex: proposal.metadata.signer_commitments_hex.clone(),
284 recipient_hex: proposal.metadata.recipient_hex.clone(),
285 faucet_id_hex: proposal.metadata.faucet_id_hex.clone(),
286 amount: proposal.metadata.amount,
287 note_type: proposal.metadata.note_type.clone(),
288 note_ids_hex: proposal.metadata.note_ids_hex.clone(),
289 consume_notes_metadata_version: proposal.metadata.consume_notes_metadata_version,
290 consume_notes_notes: proposal
291 .metadata
292 .consume_notes_notes
293 .iter()
294 .map(|n| n.as_str().to_owned())
295 .collect(),
296 new_guardian_pubkey_hex: proposal.metadata.new_guardian_pubkey_hex.clone(),
297 new_guardian_endpoint: proposal.metadata.new_guardian_endpoint.clone(),
298 target_procedure: proposal.metadata.target_procedure.clone(),
299 };
300
301 Ok(Self {
302 version: EXPORT_VERSION,
303 account_id: account_id.to_string(),
304 id: proposal.id.clone(),
305 nonce: proposal.nonce,
306 tx_summary: proposal
307 .metadata
308 .tx_summary_json
309 .clone()
310 .unwrap_or_else(|| serde_json::json!({})),
311 signatures,
312 signatures_required,
313 metadata,
314 })
315 }
316
317 pub fn with_signatures(mut self, signatures: Vec<ExportedSignature>) -> Self {
319 self.signatures = signatures;
320 self
321 }
322
323 pub fn to_proposal(&self) -> Result<Proposal> {
325 self.validate(None)?;
326
327 let tx_summary = TransactionSummary::from_json(&self.tx_summary).map_err(|e| {
328 MultisigError::InvalidConfig(format!("failed to parse tx_summary: {}", e))
329 })?;
330
331 AccountId::from_hex(&self.account_id)
332 .map_err(|e| MultisigError::InvalidConfig(format!("invalid account_id: {}", e)))?;
333
334 let metadata = self.metadata();
335 let transaction_type = metadata.to_transaction_type(&self.metadata.proposal_type)?;
336
337 let status = if self.signatures.len() >= self.signatures_required {
338 ProposalStatus::Ready
339 } else {
340 ProposalStatus::Pending
341 };
342
343 Ok(Proposal {
344 id: self.id.clone(),
345 nonce: self.nonce,
346 transaction_type,
347 status,
348 tx_summary,
349 signatures: self
350 .signatures
351 .iter()
352 .map(|signature| ProposalSignatureEntry {
353 signer_commitment: signature.signer_commitment.clone(),
354 signature_hex: signature.signature.clone(),
355 scheme: signature.scheme,
356 public_key_hex: signature.public_key_hex.clone(),
357 })
358 .collect(),
359 metadata,
360 })
361 }
362
363 pub fn signatures_collected(&self) -> usize {
365 self.signatures.len()
366 }
367
368 pub fn is_ready(&self) -> bool {
370 self.signatures.len() >= self.signatures_required
371 }
372
373 pub fn signature_counts(&self) -> (usize, usize) {
375 (self.signatures.len(), self.signatures_required)
376 }
377
378 pub fn signatures_needed(&self) -> usize {
381 self.signatures_required
382 .saturating_sub(self.signatures.len())
383 }
384
385 pub fn has_signed(&self, commitment_hex: &str) -> bool {
387 self.signatures
388 .iter()
389 .any(|s| s.signer_commitment.eq_ignore_ascii_case(commitment_hex))
390 }
391
392 pub fn signed_by(&self) -> Vec<&str> {
394 self.signatures
395 .iter()
396 .map(|s| s.signer_commitment.as_str())
397 .collect()
398 }
399
400 pub fn add_signature(&mut self, signature: ExportedSignature) -> Result<()> {
404 word_from_hex(&signature.signer_commitment).map_err(MultisigError::InvalidConfig)?;
405
406 Self {
407 version: self.version,
408 account_id: self.account_id.clone(),
409 id: self.id.clone(),
410 nonce: self.nonce,
411 tx_summary: self.tx_summary.clone(),
412 signatures: vec![signature.clone()],
413 signatures_required: self.signatures_required,
414 metadata: self.metadata.clone(),
415 }
416 .validate_signatures()?;
417
418 if self.signatures.iter().any(|s| {
419 s.signer_commitment
420 .eq_ignore_ascii_case(&signature.signer_commitment)
421 }) {
422 return Err(MultisigError::AlreadySigned);
423 }
424
425 self.signatures.push(signature);
426 Ok(())
427 }
428
429 pub fn account_id(&self) -> Result<AccountId> {
431 AccountId::from_hex(&self.account_id)
432 .map_err(|e| MultisigError::InvalidConfig(format!("invalid account_id: {}", e)))
433 }
434
435 pub fn to_json(&self) -> Result<String> {
437 serde_json::to_string_pretty(self).map_err(MultisigError::Serialization)
438 }
439
440 pub fn from_json(json: &str) -> Result<Self> {
442 let exported: Self = serde_json::from_str(json)?;
443
444 if exported.version > EXPORT_VERSION {
445 return Err(MultisigError::InvalidConfig(format!(
446 "unsupported export version {}, maximum supported is {}",
447 exported.version, EXPORT_VERSION
448 )));
449 }
450
451 exported.validate(None)?;
452
453 Ok(exported)
454 }
455}
456
457#[cfg(test)]
458mod tests {
459 use guardian_shared::ToJson;
460 use miden_client::Serializable;
461 use miden_protocol::account::AccountId;
462 use miden_protocol::account::delta::{AccountDelta, AccountStorageDelta, AccountVaultDelta};
463 use miden_protocol::crypto::dsa::falcon512_poseidon2::SecretKey;
464 use miden_protocol::transaction::{InputNotes, RawOutputNotes, TransactionSummary};
465 use miden_protocol::{Felt, Word, ZERO};
466
467 use super::*;
468 use crate::proposal::TransactionType;
469
470 #[test]
471 fn test_exported_signature_serialization() {
472 let sig = ExportedSignature {
473 signer_commitment: "0xabc123".to_string(),
474 signature: "0xdef456".to_string(),
475 scheme: SignatureScheme::Falcon,
476 public_key_hex: None,
477 };
478
479 let json = serde_json::to_string(&sig).expect("should serialize");
480 let parsed: ExportedSignature = serde_json::from_str(&json).expect("should deserialize");
481
482 assert_eq!(sig.signer_commitment, parsed.signer_commitment);
483 assert_eq!(sig.signature, parsed.signature);
484 }
485
486 #[test]
487 fn test_exported_metadata_serialization() {
488 let meta = ExportedMetadata {
489 proposal_type: "add_signer".to_string(),
490 salt_hex: Some("0x123".to_string()),
491 new_threshold: Some(2),
492 signer_commitments_hex: vec!["0xabc".to_string()],
493 recipient_hex: None,
494 faucet_id_hex: None,
495 amount: None,
496 note_type: None,
497 note_ids_hex: vec![],
498 consume_notes_metadata_version: None,
499 consume_notes_notes: Vec::new(),
500 new_guardian_pubkey_hex: None,
501 new_guardian_endpoint: None,
502 target_procedure: None,
503 };
504
505 let json = serde_json::to_string(&meta).expect("should serialize");
506 let parsed: ExportedMetadata = serde_json::from_str(&json).expect("should deserialize");
507
508 assert_eq!(meta.salt_hex, parsed.salt_hex);
509 assert_eq!(meta.new_threshold, parsed.new_threshold);
510 assert_eq!(meta.proposal_type, parsed.proposal_type);
511 }
512
513 #[test]
514 fn test_add_signature_prevents_duplicates() {
515 let mut proposal = ExportedProposal {
516 version: EXPORT_VERSION,
517 account_id: valid_account_id(),
518 id: valid_proposal_id(),
519 nonce: 1,
520 tx_summary: create_test_tx_summary().to_json(),
521 signatures: vec![],
522 signatures_required: 2,
523 metadata: ExportedMetadata {
524 proposal_type: "change_threshold".to_string(),
525 new_threshold: Some(2),
526 signer_commitments_hex: vec![valid_word_hex()],
527 ..Default::default()
528 },
529 };
530
531 let sig1 = valid_exported_signature();
532
533 proposal.add_signature(sig1.clone()).expect("should add");
535 assert_eq!(proposal.signatures.len(), 1);
536
537 let result = proposal.add_signature(sig1);
539 assert!(result.is_err());
540 assert_eq!(proposal.signatures.len(), 1);
541 }
542
543 #[test]
544 fn test_is_ready() {
545 let mut proposal = ExportedProposal {
546 version: EXPORT_VERSION,
547 account_id: "0x123".to_string(),
548 id: "0xabc".to_string(),
549 nonce: 1,
550 tx_summary: serde_json::json!({}),
551 signatures: vec![],
552 signatures_required: 2,
553 metadata: ExportedMetadata::default(),
554 };
555
556 assert!(!proposal.is_ready());
557
558 proposal.signatures.push(ExportedSignature {
559 signer_commitment: "0xsigner1".to_string(),
560 signature: "0xsig1".to_string(),
561 scheme: SignatureScheme::Falcon,
562 public_key_hex: None,
563 });
564 assert!(!proposal.is_ready());
565
566 proposal.signatures.push(ExportedSignature {
567 signer_commitment: "0xsigner2".to_string(),
568 signature: "0xsig2".to_string(),
569 scheme: SignatureScheme::Falcon,
570 public_key_hex: None,
571 });
572 assert!(proposal.is_ready());
573 }
574
575 #[test]
576 fn test_version_validation_rejects_future_exports() {
577 let json = r#"{
578 "version": 999,
579 "account_id": "0x123",
580 "id": "0xabc",
581 "nonce": 1,
582 "tx_summary": {},
583 "signatures": [],
584 "signatures_required": 2,
585 "metadata": {
586 "proposal_type": "change_threshold"
587 }
588 }"#;
589
590 let result = ExportedProposal::from_json(json);
591 assert!(result.is_err());
592 }
593
594 #[test]
595 fn test_signature_counts() {
596 let mut proposal = ExportedProposal {
597 version: EXPORT_VERSION,
598 account_id: "0x123".to_string(),
599 id: "0xabc".to_string(),
600 nonce: 1,
601 tx_summary: serde_json::json!({}),
602 signatures: vec![],
603 signatures_required: 3,
604 metadata: ExportedMetadata::default(),
605 };
606
607 assert_eq!(proposal.signature_counts(), (0, 3));
608 assert_eq!(proposal.signatures_needed(), 3);
609
610 proposal.signatures.push(ExportedSignature {
611 signer_commitment: "0xsigner1".to_string(),
612 signature: "0xsig1".to_string(),
613 scheme: SignatureScheme::Falcon,
614 public_key_hex: None,
615 });
616
617 assert_eq!(proposal.signature_counts(), (1, 3));
618 assert_eq!(proposal.signatures_needed(), 2);
619 }
620
621 #[test]
622 fn test_has_signed() {
623 let proposal = ExportedProposal {
624 version: EXPORT_VERSION,
625 account_id: "0x123".to_string(),
626 id: "0xabc".to_string(),
627 nonce: 1,
628 tx_summary: serde_json::json!({}),
629 signatures: vec![
630 ExportedSignature {
631 signer_commitment: "0xSigner1".to_string(),
632 signature: "0xsig1".to_string(),
633 scheme: SignatureScheme::Falcon,
634 public_key_hex: None,
635 },
636 ExportedSignature {
637 signer_commitment: "0xsigner2".to_string(),
638 signature: "0xsig2".to_string(),
639 scheme: SignatureScheme::Falcon,
640 public_key_hex: None,
641 },
642 ],
643 signatures_required: 3,
644 metadata: ExportedMetadata::default(),
645 };
646
647 assert!(proposal.has_signed("0xsigner1"));
649 assert!(proposal.has_signed("0xSIGNER1"));
650 assert!(proposal.has_signed("0xSigner2"));
651 assert!(!proposal.has_signed("0xsigner3"));
652 }
653
654 #[test]
655 fn test_signed_by() {
656 let proposal = ExportedProposal {
657 version: EXPORT_VERSION,
658 account_id: "0x123".to_string(),
659 id: "0xabc".to_string(),
660 nonce: 1,
661 tx_summary: serde_json::json!({}),
662 signatures: vec![
663 ExportedSignature {
664 signer_commitment: "0xsigner1".to_string(),
665 signature: "0xsig1".to_string(),
666 scheme: SignatureScheme::Falcon,
667 public_key_hex: None,
668 },
669 ExportedSignature {
670 signer_commitment: "0xsigner2".to_string(),
671 signature: "0xsig2".to_string(),
672 scheme: SignatureScheme::Falcon,
673 public_key_hex: None,
674 },
675 ],
676 signatures_required: 3,
677 metadata: ExportedMetadata::default(),
678 };
679
680 let signers = proposal.signed_by();
681 assert_eq!(signers.len(), 2);
682 assert!(signers.contains(&"0xsigner1"));
683 assert!(signers.contains(&"0xsigner2"));
684 }
685
686 fn valid_account_id() -> String {
688 "0x7b7b7b7a7b7b7b017b7b7b7b7b7b7b".to_string()
689 }
690
691 fn valid_faucet_id() -> String {
692 "0x7c7c7c7c7c7c7c017c7c7c7c7c7c7c".to_string()
693 }
694
695 fn valid_word_hex() -> String {
697 "0x0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20".to_string()
698 }
699
700 fn valid_note_id_hex() -> String {
702 "0x0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20".to_string()
703 }
704
705 fn create_test_tx_summary() -> TransactionSummary {
706 let account_id = AccountId::from_hex(&valid_account_id()).expect("valid account id");
707 let account_delta = AccountDelta::new(
708 account_id,
709 AccountStorageDelta::default(),
710 AccountVaultDelta::default(),
711 Felt::ZERO,
712 )
713 .expect("valid delta");
714
715 TransactionSummary::new(
716 account_delta,
717 InputNotes::new(Vec::new()).expect("empty input notes"),
718 RawOutputNotes::new(Vec::new()).expect("empty output notes"),
719 Word::from([Felt::new_unchecked(7), ZERO, ZERO, ZERO]),
720 )
721 }
722
723 fn valid_proposal_id() -> String {
724 ExportedProposal::expected_id(&create_test_tx_summary())
725 }
726
727 fn valid_exported_signature() -> ExportedSignature {
728 let secret_key = SecretKey::new();
729 let signature = secret_key.sign(create_test_tx_summary().to_commitment());
730 ExportedSignature {
731 signer_commitment: format!(
732 "0x{}",
733 hex::encode(secret_key.public_key().to_commitment().to_bytes())
734 ),
735 signature: format!("0x{}", hex::encode(signature.to_bytes())),
736 scheme: SignatureScheme::Falcon,
737 public_key_hex: None,
738 }
739 }
740
741 #[test]
742 fn validate_rejects_commitment_mismatch() {
743 let proposal = ExportedProposal {
744 version: EXPORT_VERSION,
745 account_id: valid_account_id(),
746 id: valid_word_hex(),
747 nonce: 1,
748 tx_summary: create_test_tx_summary().to_json(),
749 signatures: vec![],
750 signatures_required: 1,
751 metadata: ExportedMetadata {
752 proposal_type: "consume_notes".to_string(),
753 note_ids_hex: vec![valid_note_id_hex()],
754 consume_notes_metadata_version: None,
755 consume_notes_notes: Vec::new(),
756 ..Default::default()
757 },
758 };
759
760 let result = proposal.validate(None);
761 assert!(result.is_err());
762 assert!(
763 result
764 .unwrap_err()
765 .to_string()
766 .contains("does not match tx_summary commitment")
767 );
768 }
769
770 #[test]
771 fn validate_rejects_duplicate_signatures() {
772 let signature = valid_exported_signature();
773 let proposal = ExportedProposal {
774 version: EXPORT_VERSION,
775 account_id: valid_account_id(),
776 id: valid_proposal_id(),
777 nonce: 1,
778 tx_summary: create_test_tx_summary().to_json(),
779 signatures: vec![signature.clone(), signature],
780 signatures_required: 2,
781 metadata: ExportedMetadata {
782 proposal_type: "change_threshold".to_string(),
783 new_threshold: Some(2),
784 signer_commitments_hex: vec![valid_word_hex()],
785 ..Default::default()
786 },
787 };
788
789 let result = proposal.validate(None);
790 assert!(result.is_err());
791 assert!(
792 result
793 .unwrap_err()
794 .to_string()
795 .contains("duplicate exported signature")
796 );
797 }
798
799 #[test]
800 fn validate_rejects_invalid_signature_hex() {
801 let mut signature = valid_exported_signature();
802 signature.signature = "0x1234".to_string();
803
804 let proposal = ExportedProposal {
805 version: EXPORT_VERSION,
806 account_id: valid_account_id(),
807 id: valid_proposal_id(),
808 nonce: 1,
809 tx_summary: create_test_tx_summary().to_json(),
810 signatures: vec![signature],
811 signatures_required: 2,
812 metadata: ExportedMetadata {
813 proposal_type: "change_threshold".to_string(),
814 new_threshold: Some(2),
815 signer_commitments_hex: vec![valid_word_hex()],
816 ..Default::default()
817 },
818 };
819
820 let result = proposal.validate(None);
821 assert!(result.is_err());
822 assert!(
823 result
824 .unwrap_err()
825 .to_string()
826 .contains("invalid exported signature")
827 );
828 }
829 #[test]
830 fn to_proposal_uses_metadata_proposal_type_for_p2id() {
831 let proposal = ExportedProposal {
832 version: EXPORT_VERSION,
833 account_id: valid_account_id(),
834 id: valid_proposal_id(),
835 nonce: 1,
836 tx_summary: create_test_tx_summary().to_json(),
837 signatures: vec![],
838 signatures_required: 2,
839 metadata: ExportedMetadata {
840 proposal_type: "p2id".to_string(),
841 recipient_hex: Some(valid_account_id()),
842 faucet_id_hex: Some(valid_faucet_id()),
843 amount: Some(1000),
844 ..Default::default()
845 },
846 };
847
848 let parsed = proposal.to_proposal().expect("proposal should parse");
849
850 assert!(matches!(
851 parsed.transaction_type,
852 TransactionType::P2ID { amount: 1000, .. }
853 ));
854 }
855
856 #[test]
857 fn to_proposal_uses_metadata_proposal_type_for_update_signers() {
858 let proposal = ExportedProposal {
859 version: EXPORT_VERSION,
860 account_id: valid_account_id(),
861 id: valid_proposal_id(),
862 nonce: 1,
863 tx_summary: create_test_tx_summary().to_json(),
864 signatures: vec![],
865 signatures_required: 2,
866 metadata: ExportedMetadata {
867 proposal_type: "add_signer".to_string(),
868 new_threshold: Some(2),
869 signer_commitments_hex: vec![valid_word_hex()],
870 ..Default::default()
871 },
872 };
873
874 let parsed = proposal.to_proposal().expect("proposal should parse");
875
876 assert!(matches!(
877 parsed.transaction_type,
878 TransactionType::UpdateSigners {
879 new_threshold: 2,
880 ..
881 }
882 ));
883 assert_eq!(parsed.metadata.proposal_type.as_deref(), Some("add_signer"));
884 }
885
886 #[test]
887 fn to_proposal_rejects_missing_proposal_type() {
888 let json = format!(
889 r#"{{
890 "version": {version},
891 "account_id": "{account_id}",
892 "id": "{id}",
893 "nonce": 1,
894 "tx_summary": {tx_summary},
895 "signatures": [],
896 "signatures_required": 2,
897 "metadata": {{
898 "new_threshold": 2,
899 "signer_commitments_hex": ["{commitment}"]
900 }}
901 }}"#,
902 version = EXPORT_VERSION,
903 account_id = valid_account_id(),
904 id = valid_proposal_id(),
905 tx_summary = create_test_tx_summary().to_json(),
906 commitment = valid_word_hex(),
907 );
908
909 let result = ExportedProposal::from_json(&json);
910 assert!(result.is_err());
911 assert!(
912 result
913 .unwrap_err()
914 .to_string()
915 .contains("proposal metadata.proposal_type is required")
916 );
917 }
918 #[test]
919 fn from_proposal_roundtrip_preserves_proposal_type() {
920 let new_commitment = Word::from_hex(&valid_word_hex()).expect("valid signer commitment");
921 let tx_summary = create_test_tx_summary();
922 let proposal = Proposal::new(
923 tx_summary.clone(),
924 1,
925 TransactionType::AddCosigner { new_commitment },
926 ProposalMetadata {
927 tx_summary_json: Some(tx_summary.to_json()),
928 new_threshold: Some(2),
929 signer_commitments_hex: vec![valid_word_hex()],
930 required_signatures: Some(2),
931 ..Default::default()
932 },
933 );
934
935 assert_eq!(
936 proposal.metadata.proposal_type.as_deref(),
937 Some("add_signer")
938 );
939
940 let account_id = AccountId::from_hex(&valid_account_id()).expect("valid account id");
941 let exported =
942 ExportedProposal::from_proposal(&proposal, account_id).expect("proposal should export");
943
944 assert_eq!(exported.metadata.proposal_type, "add_signer");
945
946 let imported = exported.to_proposal().expect("proposal should parse");
947 assert!(matches!(
948 imported.transaction_type,
949 TransactionType::UpdateSigners {
950 new_threshold: 2,
951 ..
952 }
953 ));
954 assert_eq!(
955 imported.metadata.proposal_type.as_deref(),
956 Some("add_signer")
957 );
958 }
959
960 #[test]
961 fn from_proposal_roundtrip_preserves_custom_proposal_type() {
962 let tx_summary = create_test_tx_summary();
963 let proposal = Proposal::new(
964 tx_summary.clone(),
965 1,
966 TransactionType::Custom,
967 ProposalMetadata {
968 tx_summary_json: Some(tx_summary.to_json()),
969 proposal_type: Some("b2agg".to_string()),
970 required_signatures: Some(2),
971 ..Default::default()
972 },
973 );
974
975 assert_eq!(proposal.metadata.proposal_type.as_deref(), Some("b2agg"));
976
977 let account_id = AccountId::from_hex(&valid_account_id()).expect("valid account id");
978 let exported = ExportedProposal::from_proposal(&proposal, account_id)
979 .expect("custom proposal should export");
980 assert_eq!(exported.metadata.proposal_type, "b2agg");
981
982 let imported = exported
983 .to_proposal()
984 .expect("custom proposal should import");
985 assert_eq!(imported.transaction_type, TransactionType::Custom);
986 assert_eq!(imported.metadata.proposal_type.as_deref(), Some("b2agg"));
987 }
988
989 #[test]
990 fn from_proposal_rejects_ambiguous_update_signers_without_proposal_type() {
991 let tx_summary = create_test_tx_summary();
992 let proposal = Proposal::new(
993 tx_summary.clone(),
994 1,
995 TransactionType::UpdateSigners {
996 new_threshold: 2,
997 signer_commitments: vec![Word::from_hex(&valid_word_hex()).expect("valid word")],
998 },
999 ProposalMetadata {
1000 tx_summary_json: Some(tx_summary.to_json()),
1001 new_threshold: Some(2),
1002 signer_commitments_hex: vec![valid_word_hex()],
1003 required_signatures: Some(2),
1004 ..Default::default()
1005 },
1006 );
1007
1008 let account_id = AccountId::from_hex(&valid_account_id()).expect("valid account id");
1009 let result = ExportedProposal::from_proposal(&proposal, account_id);
1010 assert!(result.is_err());
1011 assert!(
1012 result
1013 .unwrap_err()
1014 .to_string()
1015 .contains("cannot export signer update proposal without metadata.proposal_type")
1016 );
1017 }
1018
1019 #[test]
1020 fn to_proposal_uses_metadata_proposal_type_for_update_procedure_threshold() {
1021 let proposal = ExportedProposal {
1022 version: EXPORT_VERSION,
1023 account_id: valid_account_id(),
1024 id: valid_proposal_id(),
1025 nonce: 1,
1026 tx_summary: create_test_tx_summary().to_json(),
1027 signatures: vec![],
1028 signatures_required: 2,
1029 metadata: ExportedMetadata {
1030 proposal_type: "update_procedure_threshold".to_string(),
1031 new_threshold: Some(1),
1032 target_procedure: Some("send_asset".to_string()),
1033 ..Default::default()
1034 },
1035 };
1036
1037 let parsed = proposal.to_proposal().expect("proposal should parse");
1038
1039 assert!(matches!(
1040 parsed.transaction_type,
1041 TransactionType::UpdateProcedureThreshold {
1042 procedure: crate::ProcedureName::SendAsset,
1043 new_threshold: 1
1044 }
1045 ));
1046 }
1047}