Skip to main content

miden_multisig_client/
export.rs

1//! Export/import types for offline proposal sharing.
2//!
3//! This module provides types and utilities for exporting proposals to files
4//! and importing them back. This enables offline sharing of proposals via
5//! side channels (email, USB, etc.) when the GUARDIAN server is unavailable.
6//!
7
8use 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
29/// Current export format version.
30pub const EXPORT_VERSION: u32 = 1;
31
32fn default_signature_scheme() -> SignatureScheme {
33    SignatureScheme::Falcon
34}
35
36/// Exported proposal for offline sharing.
37#[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/// A signature collected for an exported proposal.
55#[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/// Metadata needed for proposal reconstruction.
66#[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    /// `consume_notes` proposal-metadata schema version (issue #229).
93    /// Mirrors `ProposalMetadataPayload::consume_notes_metadata_version`.
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub consume_notes_metadata_version: Option<u32>,
96
97    /// `consume_notes` v2 embedded notes; base64 of Miden `Note`
98    /// serialization, aligned by index with `note_ids_hex`.
99    #[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    /// Creates an ExportedProposal from a Proposal and account ID.
252    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    /// Creates an ExportedProposal with signatures from raw data.
311    pub fn with_signatures(mut self, signatures: Vec<ExportedSignature>) -> Self {
312        self.signatures = signatures;
313        self
314    }
315
316    /// Converts the ExportedProposal back to a Proposal.
317    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    /// Returns the number of signatures collected.
357    pub fn signatures_collected(&self) -> usize {
358        self.signatures.len()
359    }
360
361    /// Returns true if the proposal has enough signatures for execution.
362    pub fn is_ready(&self) -> bool {
363        self.signatures.len() >= self.signatures_required
364    }
365
366    /// Returns (collected, required) signature counts.
367    pub fn signature_counts(&self) -> (usize, usize) {
368        (self.signatures.len(), self.signatures_required)
369    }
370
371    /// Returns the number of additional signatures needed for finalization.
372    /// Returns 0 if the proposal is ready.
373    pub fn signatures_needed(&self) -> usize {
374        self.signatures_required
375            .saturating_sub(self.signatures.len())
376    }
377
378    /// Checks if a signer (by commitment hex) has already signed this proposal.
379    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    /// Returns the commitment hex strings of all signers who have signed.
386    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    /// Adds a signature to the proposal.
394    ///
395    /// Returns an error if the signer has already signed.
396    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    /// Returns the account ID as an AccountId.
423    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    /// Serializes the proposal to a JSON string.
429    pub fn to_json(&self) -> Result<String> {
430        serde_json::to_string_pretty(self).map_err(MultisigError::Serialization)
431    }
432
433    /// Deserializes a proposal from a JSON string.
434    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        // First signature should succeed
526        proposal.add_signature(sig1.clone()).expect("should add");
527        assert_eq!(proposal.signatures.len(), 1);
528
529        // Duplicate should fail
530        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        // Test case-insensitive matching
640        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    // Helper for valid account ID (15 bytes = 30 hex chars)
679    fn valid_account_id() -> String {
680        "0x7b7b7b7a7b7b7b017b7b7b7b7b7b7b".to_string()
681    }
682
683    fn valid_faucet_id() -> String {
684        "0x7c7c7c7c7c7c7c017c7c7c7c7c7c7c".to_string()
685    }
686
687    // Helper for valid 32-byte hex (Word)
688    fn valid_word_hex() -> String {
689        "0x0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20".to_string()
690    }
691
692    // Helper for valid note ID hex (32 bytes)
693    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}