miden_multisig_client/client/
io.rs1use 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 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 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 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 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 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 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 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 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 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 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(¬e_file);
245
246 let commitments = self
247 .miden_client
248 .import_notes(std::slice::from_ref(¬e_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
264fn 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 let id_file = NoteFile::NoteId(note.id());
307 assert_eq!(note_file_note_id(&id_file), Some(expected));
308
309 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}