Skip to main content

miden_multisig_client/client/
io.rs

1//! Export/import operations for MultisigClient.
2//!
3//! This module handles exporting proposals to files/strings and
4//! importing them back for offline sharing workflows, as well as
5//! exporting/importing note files for out-of-band note transfer
6//! (issue #356).
7
8use guardian_client::delta_status::Status;
9use guardian_shared::SignatureScheme;
10use miden_client::note::NoteFile;
11use miden_client::store::NoteExportType;
12use miden_protocol::note::NoteId;
13use miden_protocol::utils::serde::{Deserializable, Serializable};
14
15use super::MultisigClient;
16use crate::error::{MultisigError, Result};
17use crate::export::{ExportedProposal, ExportedSignature};
18
19impl MultisigClient {
20    /// Exports a proposal to a file for offline sharing.
21    ///
22    /// This fetches the proposal from GUARDIAN, including all collected signatures,
23    /// and writes it to the specified file path as JSON.
24    ///
25    /// # Example
26    ///
27    /// ```ignore
28    /// client.export_proposal(&proposal_id, "/tmp/proposal.json").await?;
29    /// ```
30    pub async fn export_proposal(
31        &mut self,
32        proposal_id: &str,
33        path: &std::path::Path,
34    ) -> Result<()> {
35        let exported = self.export_proposal_to_exported(proposal_id).await?;
36        let json = exported.to_json()?;
37        std::fs::write(path, json)
38            .map_err(|e| MultisigError::InvalidConfig(format!("failed to write file: {}", e)))?;
39        Ok(())
40    }
41
42    /// Exports a proposal to a JSON string for programmatic use.
43    ///
44    /// # Example
45    ///
46    /// ```ignore
47    /// let json = client.export_proposal_to_string(&proposal_id).await?;
48    /// println!("{}", json);
49    /// ```
50    pub async fn export_proposal_to_string(&mut self, proposal_id: &str) -> Result<String> {
51        let exported = self.export_proposal_to_exported(proposal_id).await?;
52        exported.to_json()
53    }
54
55    /// Internal helper to create an ExportedProposal from GUARDIAN data.
56    ///
57    /// # Errors
58    ///
59    /// Returns an error if:
60    /// - The proposal is not found in GUARDIAN
61    /// - The raw delta cannot be found in GUARDIAN response
62    /// - The delta has no pending status with signature data
63    async fn export_proposal_to_exported(&mut self, proposal_id: &str) -> Result<ExportedProposal> {
64        let account = self.require_account()?.clone();
65        let account_id = account.id();
66        let mut guardian_client = self.create_authenticated_guardian_client().await?;
67        let response = guardian_client
68            .get_delta_proposal(&account_id, proposal_id)
69            .await
70            .map_err(|e| MultisigError::GuardianServer(format!("failed to get proposal: {}", e)))?;
71        let raw_proposal = response
72            .proposal
73            .as_ref()
74            .ok_or_else(|| MultisigError::ProposalNotFound(proposal_id.to_string()))?;
75        Self::ensure_proposal_account_id(&raw_proposal.account_id, &account_id)?;
76        let proposal = crate::proposal::Proposal::from(raw_proposal)?;
77        self.verify_proposal_summary_binding(&proposal).await?;
78
79        // Extract signatures - fail if status structure is missing
80        let status = raw_proposal.status.as_ref().ok_or_else(|| {
81            MultisigError::GuardianServer(format!("proposal {} has no status field", proposal_id))
82        })?;
83
84        let status_oneof = status.status.as_ref().ok_or_else(|| {
85            MultisigError::GuardianServer(format!("proposal {} has empty status", proposal_id))
86        })?;
87
88        let pending = match status_oneof {
89            Status::Pending(p) => p,
90            _ => {
91                return Err(MultisigError::GuardianServer(format!(
92                    "proposal {} is not in pending state",
93                    proposal_id
94                )));
95            }
96        };
97
98        let mut signatures = Vec::new();
99        for cosigner_sig in pending.cosigner_sigs.iter() {
100            if let Some(ref sig) = cosigner_sig.signature {
101                let scheme = if sig.scheme.eq_ignore_ascii_case("ecdsa") {
102                    SignatureScheme::Ecdsa
103                } else {
104                    SignatureScheme::Falcon
105                };
106                signatures.push(ExportedSignature {
107                    signer_commitment: cosigner_sig.signer_id.clone(),
108                    signature: sig.signature.clone(),
109                    scheme,
110                    public_key_hex: sig.public_key.clone(),
111                });
112            }
113        }
114
115        let exported =
116            ExportedProposal::from_proposal(&proposal, account_id)?.with_signatures(signatures);
117
118        Ok(exported)
119    }
120
121    /// Imports a proposal from a file.
122    ///
123    /// The proposal can then be signed with `sign_imported_proposal`
124    /// or executed with `execute_imported_proposal`.
125    ///
126    /// # Example
127    ///
128    /// ```ignore
129    /// let proposal = client.import_proposal("/tmp/proposal.json").await?;
130    /// println!("Imported proposal: {}", proposal.id);
131    /// ```
132    pub async fn import_proposal(&mut self, path: &std::path::Path) -> Result<ExportedProposal> {
133        let json = std::fs::read_to_string(path)
134            .map_err(|e| MultisigError::InvalidConfig(format!("failed to read file: {}", e)))?;
135        self.import_proposal_from_string(&json).await
136    }
137
138    /// Imports a proposal from a JSON string.
139    ///
140    /// # Example
141    ///
142    /// ```ignore
143    /// let proposal = client.import_proposal_from_string(&json).await?;
144    /// ```
145    pub async fn import_proposal_from_string(&mut self, json: &str) -> Result<ExportedProposal> {
146        let exported = ExportedProposal::from_json(json)?;
147        exported.validate(self.account.as_ref().map(|account| account.id()))?;
148
149        let proposal = exported.to_proposal()?;
150        self.verify_proposal_summary_binding(&proposal).await?;
151
152        Ok(exported)
153    }
154
155    /// Exports a note created by this account to a file for out-of-band
156    /// delivery (issue #356).
157    ///
158    /// A private note publishes only its commitment on chain, so the recipient
159    /// can never learn its contents via sync; the sender must hand them the
160    /// note file produced here, which they load with
161    /// [`Self::import_note_from_file`].
162    ///
163    /// # Example
164    ///
165    /// ```ignore
166    /// client.export_note_to_file(&note_id_hex, Path::new("note.mno")).await?;
167    /// ```
168    pub async fn export_note_to_file(&self, note_id: &str, path: &std::path::Path) -> Result<()> {
169        let bytes = self.export_note_to_bytes(note_id).await?;
170        tokio::fs::write(path, bytes)
171            .await
172            .map_err(|e| MultisigError::InvalidConfig(format!("failed to write file: {}", e)))?;
173        Ok(())
174    }
175
176    /// Exports a note created by this account to serialized `NoteFile` bytes
177    /// for programmatic out-of-band delivery (issue #356).
178    ///
179    /// The note must be an output note of this client (i.e. created by a
180    /// transaction this client executed). When the note's on-chain inclusion
181    /// proof is already known (after a post-commit sync) the full note with
182    /// proof is exported; otherwise the note details are exported and the
183    /// importer's client tracks the note until it commits on chain.
184    pub async fn export_note_to_bytes(&self, note_id: &str) -> Result<Vec<u8>> {
185        let note_id = NoteId::try_from_hex(note_id.trim())
186            .map_err(|e| MultisigError::InvalidConfig(format!("invalid note id: {}", e)))?;
187
188        let record = self
189            .miden_client
190            .get_output_note(note_id)
191            .await
192            .map_err(|e| MultisigError::MidenClient(format!("failed to get output note: {}", e)))?
193            .ok_or_else(|| {
194                MultisigError::MidenClient(format!(
195                    "output note {} not found in the local store; only notes created by \
196                     this client can be exported",
197                    note_id.to_hex()
198                ))
199            })?;
200
201        let export_type = if record.inclusion_proof().is_some() {
202            NoteExportType::NoteWithProof
203        } else {
204            NoteExportType::NoteDetails
205        };
206
207        let note_file = record.into_note_file(&export_type).map_err(|e| {
208            MultisigError::MidenClient(format!("failed to convert note for export: {}", e))
209        })?;
210
211        Ok(note_file.to_bytes())
212    }
213
214    /// Imports a note file received out-of-band (issue #356) so the note can
215    /// be consumed by this account.
216    ///
217    /// Returns the note ID when the file carries one (full note with proof or
218    /// ID-only file), or the note's details commitment for a details-only
219    /// file. Sync afterwards so the note's on-chain commitment is tracked and
220    /// the note shows up in [`Self::list_consumable_notes`].
221    ///
222    /// # Example
223    ///
224    /// ```ignore
225    /// let note_id = client.import_note_from_file(Path::new("note.mno")).await?;
226    /// client.sync().await?;
227    /// ```
228    pub async fn import_note_from_file(&mut self, path: &std::path::Path) -> Result<String> {
229        let bytes = tokio::fs::read(path)
230            .await
231            .map_err(|e| MultisigError::InvalidConfig(format!("failed to read file: {}", e)))?;
232        self.import_note_from_bytes(&bytes).await
233    }
234
235    /// Imports a note from serialized `NoteFile` bytes (issue #356).
236    ///
237    /// See [`Self::import_note_from_file`] for the returned identifier
238    /// semantics.
239    pub async fn import_note_from_bytes(&mut self, bytes: &[u8]) -> Result<String> {
240        let note_file = NoteFile::read_from_bytes(bytes).map_err(|e| {
241            MultisigError::InvalidConfig(format!("failed to decode note file: {}", e))
242        })?;
243
244        let known_id = note_file_note_id(&note_file);
245
246        let commitments = self
247            .miden_client
248            .import_notes(std::slice::from_ref(&note_file))
249            .await
250            .map_err(|e| MultisigError::MidenClient(format!("failed to import note: {}", e)))?;
251
252        match known_id {
253            Some(id) => Ok(id),
254            None => commitments
255                .first()
256                .map(|c| format!("0x{}", hex::encode(c.to_bytes())))
257                .ok_or_else(|| {
258                    MultisigError::MidenClient("note import reported no imported notes".to_string())
259                }),
260        }
261    }
262}
263
264/// Returns the note ID a note file resolves to, when it carries one. A
265/// details-only file has no metadata and therefore no note ID yet.
266fn note_file_note_id(note_file: &NoteFile) -> Option<String> {
267    match note_file {
268        NoteFile::NoteId(id) => Some(id.to_hex()),
269        NoteFile::NoteWithProof(note, _) => Some(note.id().to_hex()),
270        NoteFile::NoteDetails { .. } => None,
271    }
272}
273
274#[cfg(test)]
275mod tests {
276    use miden_protocol::Word;
277    use miden_protocol::account::AccountId;
278    use miden_protocol::block::BlockNumber;
279    use miden_protocol::crypto::rand::RandomCoin;
280    use miden_protocol::note::{Note, NoteType};
281    use miden_standards::note::P2idNote;
282
283    use super::*;
284
285    fn build_test_note() -> Note {
286        let sender = AccountId::from_hex("0x7b7b7b7a7b7b7b017b7b7b7b7b7b7b").unwrap();
287        let target = AccountId::from_hex("0x1b1b1b1a1b1b1b011b1b1b1b1b1b1b").unwrap();
288        let mut rng = RandomCoin::new(Word::default());
289        P2idNote::create(
290            sender,
291            target,
292            vec![],
293            NoteType::Private,
294            Default::default(),
295            &mut rng,
296        )
297        .unwrap()
298    }
299
300    #[test]
301    fn note_file_note_id_by_variant() {
302        let note = build_test_note();
303        let expected = note.id().to_hex();
304
305        // An ID-only file resolves to the note ID.
306        let id_file = NoteFile::NoteId(note.id());
307        assert_eq!(note_file_note_id(&id_file), Some(expected));
308
309        // A details-only file has no note ID yet.
310        let details_file = NoteFile::NoteDetails {
311            details: note.into(),
312            after_block_num: BlockNumber::from(0u32),
313            tag: None,
314        };
315        assert_eq!(note_file_note_id(&details_file), None);
316    }
317
318    #[test]
319    fn note_file_roundtrips_through_bytes() {
320        let note = build_test_note();
321        let file = NoteFile::NoteDetails {
322            details: note.into(),
323            after_block_num: BlockNumber::from(7u32),
324            tag: None,
325        };
326
327        let bytes = file.to_bytes();
328        let decoded = NoteFile::read_from_bytes(&bytes).unwrap();
329        match decoded {
330            NoteFile::NoteDetails {
331                after_block_num, ..
332            } => assert_eq!(after_block_num, BlockNumber::from(7u32)),
333            _ => panic!("expected details variant"),
334        }
335
336        assert!(NoteFile::read_from_bytes(b"not a note file").is_err());
337    }
338}