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    /// P2ID note visibility, `"public"` or `"private"` (issue #322).
90    /// Absent => public (pre-#322 exports).
91    #[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    /// `consume_notes` proposal-metadata schema version (issue #229).
98    /// Mirrors `ProposalMetadataPayload::consume_notes_metadata_version`.
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub consume_notes_metadata_version: Option<u32>,
101
102    /// `consume_notes` v2 embedded notes; base64 of Miden `Note`
103    /// serialization, aligned by index with `note_ids_hex`.
104    #[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    /// Creates an ExportedProposal from a Proposal and account ID.
258    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    /// Creates an ExportedProposal with signatures from raw data.
318    pub fn with_signatures(mut self, signatures: Vec<ExportedSignature>) -> Self {
319        self.signatures = signatures;
320        self
321    }
322
323    /// Converts the ExportedProposal back to a Proposal.
324    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    /// Returns the number of signatures collected.
364    pub fn signatures_collected(&self) -> usize {
365        self.signatures.len()
366    }
367
368    /// Returns true if the proposal has enough signatures for execution.
369    pub fn is_ready(&self) -> bool {
370        self.signatures.len() >= self.signatures_required
371    }
372
373    /// Returns (collected, required) signature counts.
374    pub fn signature_counts(&self) -> (usize, usize) {
375        (self.signatures.len(), self.signatures_required)
376    }
377
378    /// Returns the number of additional signatures needed for finalization.
379    /// Returns 0 if the proposal is ready.
380    pub fn signatures_needed(&self) -> usize {
381        self.signatures_required
382            .saturating_sub(self.signatures.len())
383    }
384
385    /// Checks if a signer (by commitment hex) has already signed this proposal.
386    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    /// Returns the commitment hex strings of all signers who have signed.
393    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    /// Adds a signature to the proposal.
401    ///
402    /// Returns an error if the signer has already signed.
403    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    /// Returns the account ID as an AccountId.
430    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    /// Serializes the proposal to a JSON string.
436    pub fn to_json(&self) -> Result<String> {
437        serde_json::to_string_pretty(self).map_err(MultisigError::Serialization)
438    }
439
440    /// Deserializes a proposal from a JSON string.
441    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        // First signature should succeed
534        proposal.add_signature(sig1.clone()).expect("should add");
535        assert_eq!(proposal.signatures.len(), 1);
536
537        // Duplicate should fail
538        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        // Test case-insensitive matching
648        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    // Helper for valid account ID (15 bytes = 30 hex chars)
687    fn valid_account_id() -> String {
688        "0x7b7b7b7a7b7b7b017b7b7b7b7b7b7b".to_string()
689    }
690
691    fn valid_faucet_id() -> String {
692        "0x7c7c7c7c7c7c7c017c7c7c7c7c7c7c".to_string()
693    }
694
695    // Helper for valid 32-byte hex (Word)
696    fn valid_word_hex() -> String {
697        "0x0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20".to_string()
698    }
699
700    // Helper for valid note ID hex (32 bytes)
701    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}