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