1use std::collections::HashSet;
7
8use guardian_shared::{ProposalSignature, ToJson};
9use miden_client::transaction::TransactionRequest;
10
11use super::{MultisigClient, ProposalResult};
12use crate::error::{MultisigError, Result};
13use crate::execution::{
14 SignatureAdvice, SignatureInput, build_final_transaction_request, collect_signature_advice,
15};
16use crate::keystore::proposal_public_key_hex;
17use crate::proposal::{Proposal, TransactionType, is_builtin_proposal_type};
18use crate::transaction::{
19 ProposalBuilder, deserialize_transaction_request, execute_for_summary, word_to_hex,
20};
21
22impl MultisigClient {
23 async fn get_proposal(
24 &mut self,
25 account_id: &miden_protocol::account::AccountId,
26 proposal_id: &str,
27 ) -> Result<Proposal> {
28 let mut guardian_client = self.create_authenticated_guardian_client().await?;
29 let response = guardian_client
30 .get_delta_proposal(account_id, proposal_id)
31 .await
32 .map_err(|e| MultisigError::GuardianServer(format!("failed to get proposal: {}", e)))?;
33
34 let raw_proposal = response
35 .proposal
36 .ok_or_else(|| MultisigError::ProposalNotFound(proposal_id.to_string()))?;
37 Self::ensure_proposal_account_id(&raw_proposal.account_id, account_id)?;
38 let proposal = Proposal::from(&raw_proposal)?;
39 self.verify_proposal_summary_binding(&proposal).await?;
40 Ok(proposal)
41 }
42
43 pub async fn list_proposals(&mut self) -> Result<Vec<Proposal>> {
54 let (account_id, current_nonce) = {
55 let account = self.require_account()?;
56 (account.id(), account.nonce())
57 };
58
59 let mut guardian_client = self.create_authenticated_guardian_client().await?;
60
61 let response = guardian_client
62 .get_delta_proposals(&account_id)
63 .await
64 .map_err(|e| {
65 MultisigError::GuardianServer(format!("failed to get proposals: {}", e))
66 })?;
67
68 let mut proposals = Vec::with_capacity(response.proposals.len());
69 for delta in &response.proposals {
70 Self::ensure_proposal_account_id(&delta.account_id, &account_id)?;
71 let proposal = Proposal::from(delta)?;
72
73 if proposal.nonce <= current_nonce {
74 continue;
75 }
76
77 self.verify_proposal_summary_binding(&proposal).await?;
78 proposals.push(proposal);
79 }
80
81 Ok(proposals)
82 }
83
84 pub async fn sign_proposal(&mut self, proposal_id: &str) -> Result<Proposal> {
86 let account = self.require_account()?;
87
88 let user_commitment = self.key_manager.commitment();
90 if !account.is_cosigner(&user_commitment) {
91 return Err(MultisigError::NotCosigner);
92 }
93
94 let account_id = account.id();
95 let proposal = self.get_proposal(&account_id, proposal_id).await?;
96
97 if proposal.has_signed(&self.key_manager.commitment_hex()) {
99 return Err(MultisigError::AlreadySigned);
100 }
101
102 let tx_commitment = proposal.tx_summary.to_commitment();
104 let signature_hex = self.key_manager.sign_word_hex(tx_commitment);
105
106 let signature = ProposalSignature::from_scheme(
108 self.key_manager.scheme(),
109 signature_hex,
110 proposal_public_key_hex(self.key_manager.as_ref()),
111 );
112
113 let mut guardian_client = self.create_authenticated_guardian_client().await?;
115 let sign_response = guardian_client
116 .sign_delta_proposal(&account_id, proposal_id, signature)
117 .await
118 .map_err(|e| {
119 MultisigError::GuardianServer(format!("failed to sign proposal: {}", e))
120 })?;
121
122 let updated_raw = sign_response
123 .delta
124 .as_ref()
125 .ok_or_else(|| MultisigError::ProposalNotFound(proposal_id.to_string()))?;
126 Self::ensure_proposal_account_id(&updated_raw.account_id, &account_id)?;
127 let updated = Proposal::from(updated_raw)?;
128 Ok(updated)
129 }
130
131 pub async fn execute_proposal(&mut self, proposal_id: &str) -> Result<()> {
152 self.sync().await?;
154
155 let account = self.require_account()?.clone();
156 let account_id = account.id();
157
158 let proposal = self.get_proposal(&account_id, proposal_id).await?;
159
160 if proposal.nonce <= account.nonce() {
161 return Err(MultisigError::InvalidConfig(format!(
162 "proposal nonce {} is not greater than the current account nonce {}; \
163 it has already been executed or superseded",
164 proposal.nonce,
165 account.nonce()
166 )));
167 }
168
169 if !proposal.status.is_ready() {
171 let (collected, required) = proposal.signature_counts();
172 return Err(MultisigError::ProposalNotReady {
173 collected,
174 required,
175 });
176 }
177
178 if matches!(proposal.transaction_type, TransactionType::Custom) {
182 return Err(MultisigError::UnsupportedTransactionType(
183 "custom proposals are executed by the integration; call \
184 prepare_custom_execution to get the cosigner + GUARDIAN advice"
185 .to_string(),
186 ));
187 }
188
189 let tx_summary_commitment = proposal.tx_summary.to_commitment();
190
191 let mut signature_inputs: Vec<SignatureInput> = proposal
192 .signatures
193 .into_iter()
194 .map(|signature| SignatureInput {
195 signer_commitment: signature.signer_commitment,
196 signature_hex: signature.signature_hex,
197 scheme: signature.scheme,
198 public_key_hex: signature.public_key_hex,
199 })
200 .collect();
201
202 signature_inputs.sort_by(|a, b| a.signer_commitment.cmp(&b.signer_commitment));
204 signature_inputs.dedup_by(|a, b| a.signer_commitment == b.signer_commitment);
205
206 let required_commitments: HashSet<String> =
210 account.cosigner_commitments_hex().into_iter().collect();
211 let mut signature_advice = collect_signature_advice(
212 signature_inputs,
213 &required_commitments,
214 tx_summary_commitment,
215 )?;
216
217 if proposal.transaction_type.requires_guardian_ack() {
218 let guardian_advice = self
220 .get_guardian_ack_signature(
221 &account,
222 proposal.nonce,
223 &proposal.tx_summary,
224 tx_summary_commitment,
225 )
226 .await?;
227 signature_advice.push(guardian_advice);
228 } else {
229 let _ = self
230 .get_guardian_ack_signature(
231 &account,
232 proposal.nonce,
233 &proposal.tx_summary,
234 tx_summary_commitment,
235 )
236 .await;
237 }
238
239 let salt = proposal.metadata.salt()?;
241
242 let signer_commitments = if matches!(
245 &proposal.transaction_type,
246 TransactionType::AddCosigner { .. }
247 | TransactionType::RemoveCosigner { .. }
248 | TransactionType::UpdateSigners { .. }
249 ) {
250 Some(proposal.metadata.signer_commitments()?)
251 } else {
252 proposal.metadata.signer_commitments().ok()
253 };
254
255 let final_tx_request = build_final_transaction_request(
256 &self.miden_client,
257 &proposal.transaction_type,
258 account.inner(),
259 salt,
260 signature_advice,
261 proposal.metadata.new_threshold,
262 signer_commitments.as_deref(),
263 self.key_manager.scheme(),
264 )
265 .await?;
266
267 self.finalize_transaction(account_id, final_tx_request, &proposal.transaction_type)
269 .await
270 }
271
272 pub async fn propose_custom_transaction(
278 &mut self,
279 transaction_request_bytes: &[u8],
280 proposal_type: &str,
281 ) -> Result<Proposal> {
282 let proposal_type = proposal_type.trim().to_lowercase();
283 if proposal_type.is_empty() {
284 return Err(MultisigError::InvalidConfig(
285 "proposal_type must not be empty".to_string(),
286 ));
287 }
288 if !proposal_type
289 .bytes()
290 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')
291 {
292 return Err(MultisigError::InvalidConfig(format!(
293 "proposal_type '{}' must be lowercase snake_case ([a-z0-9_]): no spaces, hyphens, or other characters",
294 proposal_type
295 )));
296 }
297 if is_builtin_proposal_type(&proposal_type) {
298 return Err(MultisigError::UnsupportedTransactionType(format!(
299 "'{}' is a built-in proposal type; use the typed proposal API instead",
300 proposal_type
301 )));
302 }
303
304 self.sync().await?;
305 let account = self.require_account()?.clone();
306 let account_id = account.id();
307
308 let tx_request = deserialize_transaction_request(transaction_request_bytes)?;
309 let tx_summary =
310 execute_for_summary(&mut self.miden_client, account_id, tx_request).await?;
311 let tx_commitment = tx_summary.to_commitment();
312
313 let required_signatures = account.threshold()? as usize;
314
315 let metadata = crate::proposal::ProposalMetadata {
316 tx_summary_json: Some(tx_summary.to_json()),
317 proposal_type: Some(proposal_type.to_string()),
318 required_signatures: Some(required_signatures),
319 signers: vec![self.key_manager.commitment_hex()],
320 ..Default::default()
321 };
322
323 let payload = crate::payload::ProposalPayload::new(&tx_summary)
324 .with_signature(self.key_manager.as_ref(), tx_commitment)
325 .with_custom_metadata(proposal_type.to_string())
326 .with_required_signatures(required_signatures);
327
328 let nonce = account.nonce() + 1;
329 let mut guardian_client = self.create_authenticated_guardian_client().await?;
330 let response = guardian_client
331 .push_delta_proposal(&account_id, nonce, &payload.to_json())
332 .await
333 .map_err(|e| {
334 MultisigError::GuardianServer(format!("failed to push proposal: {}", e))
335 })?;
336
337 let proposal = Proposal::new(tx_summary, nonce, TransactionType::Custom, metadata);
338
339 if !proposal
340 .id
341 .trim_start_matches("0x")
342 .eq_ignore_ascii_case(response.commitment.trim_start_matches("0x"))
343 {
344 return Err(MultisigError::GuardianServer(format!(
345 "GUARDIAN returned proposal commitment {} but expected {}",
346 response.commitment, proposal.id
347 )));
348 }
349
350 Ok(proposal)
351 }
352
353 pub async fn prepare_custom_execution(
365 &mut self,
366 proposal_id: &str,
367 transaction_request_bytes: &[u8],
368 ) -> Result<Vec<SignatureAdvice>> {
369 self.sync().await?;
370 let account = self.require_account()?.clone();
371 let account_id = account.id();
372
373 let proposal = self.get_proposal(&account_id, proposal_id).await?;
374
375 if !matches!(proposal.transaction_type, TransactionType::Custom) {
376 return Err(MultisigError::UnsupportedTransactionType(
377 "prepare_custom_execution is only for custom proposals; use execute_proposal \
378 for built-in types"
379 .to_string(),
380 ));
381 }
382
383 if !proposal.status.is_ready() {
384 let (collected, required) = proposal.signature_counts();
385 return Err(MultisigError::ProposalNotReady {
386 collected,
387 required,
388 });
389 }
390
391 let tx_summary_commitment = proposal.tx_summary.to_commitment();
392
393 let probe_request = deserialize_transaction_request(transaction_request_bytes)?;
394 let derived_summary =
395 execute_for_summary(&mut self.miden_client, account_id, probe_request).await?;
396 let derived_commitment = derived_summary.to_commitment();
397 if derived_commitment != tx_summary_commitment {
398 return Err(MultisigError::InvalidConfig(format!(
399 "transaction request does not match the signed proposal commitment \
400 (expected {}, got {})",
401 word_to_hex(&tx_summary_commitment),
402 word_to_hex(&derived_commitment)
403 )));
404 }
405
406 let mut signature_inputs: Vec<SignatureInput> = proposal
407 .signatures
408 .into_iter()
409 .map(|signature| SignatureInput {
410 signer_commitment: signature.signer_commitment,
411 signature_hex: signature.signature_hex,
412 scheme: signature.scheme,
413 public_key_hex: signature.public_key_hex,
414 })
415 .collect();
416 signature_inputs.sort_by(|a, b| a.signer_commitment.cmp(&b.signer_commitment));
417 signature_inputs.dedup_by(|a, b| a.signer_commitment == b.signer_commitment);
418
419 let required_commitments: HashSet<String> =
420 account.cosigner_commitments_hex().into_iter().collect();
421 let mut signature_advice = collect_signature_advice(
422 signature_inputs,
423 &required_commitments,
424 tx_summary_commitment,
425 )?;
426
427 if proposal.transaction_type.requires_guardian_ack() {
428 let guardian_advice = self
429 .get_guardian_ack_signature(
430 &account,
431 proposal.nonce,
432 &derived_summary,
433 tx_summary_commitment,
434 )
435 .await?;
436 signature_advice.push(guardian_advice);
437 }
438
439 Ok(signature_advice)
440 }
441
442 pub async fn submit_transaction(&mut self, request: TransactionRequest) -> Result<()> {
447 self.sync().await?;
451 let account_id = self.require_account()?.id();
452 self.miden_client
453 .submit_new_transaction(account_id, request)
454 .await
455 .map_err(|e| {
456 MultisigError::TransactionExecution(format!(
457 "transaction submission failed: {:?}",
458 e
459 ))
460 })?;
461 let _ = self.miden_client.sync_state().await;
462 Ok(())
463 }
464
465 pub async fn propose_transaction(
486 &mut self,
487 transaction_type: TransactionType,
488 ) -> Result<Proposal> {
489 self.sync().await?;
491
492 let account = self.require_account()?.clone();
493 let mut guardian_client = self.create_authenticated_guardian_client().await?;
494
495 ProposalBuilder::new(transaction_type)
496 .build(
497 &mut self.miden_client,
498 &mut guardian_client,
499 &account,
500 self.key_manager.as_ref(),
501 )
502 .await
503 }
504
505 pub async fn propose_with_fallback(
540 &mut self,
541 transaction_type: TransactionType,
542 ) -> Result<ProposalResult> {
543 match self.propose_transaction(transaction_type.clone()).await {
545 Ok(proposal) => Ok(ProposalResult::Online(Box::new(proposal))),
546 Err(
547 error @ (MultisigError::GuardianConnection(_) | MultisigError::GuardianServer(_)),
548 ) => {
549 if transaction_type.supports_offline_execution() {
550 let exported = self.create_proposal_offline(transaction_type).await?;
551 Ok(ProposalResult::Offline(Box::new(exported)))
552 } else {
553 Err(error)
554 }
555 }
556 Err(e) => Err(e),
557 }
558 }
559}
560
561#[cfg(test)]
562mod tests {
563 use guardian_client::DeltaObject;
564 use guardian_shared::ToJson;
565 use miden_protocol::account::AccountId;
566 use miden_protocol::account::delta::{AccountDelta, AccountStorageDelta, AccountVaultDelta};
567 use miden_protocol::transaction::{InputNotes, RawOutputNotes, TransactionSummary};
568 use miden_protocol::{Felt, Word, ZERO};
569
570 use crate::error::{MultisigError, Result};
571 use crate::proposal::Proposal;
572
573 fn create_test_tx_summary(account_id: &str, seed: u64) -> TransactionSummary {
574 let account_id = AccountId::from_hex(account_id).expect("valid account id");
575 let account_delta = AccountDelta::new(
576 account_id,
577 AccountStorageDelta::default(),
578 AccountVaultDelta::default(),
579 Felt::ZERO,
580 )
581 .expect("valid delta");
582
583 TransactionSummary::new(
584 account_delta,
585 InputNotes::new(Vec::new()).expect("empty input notes"),
586 RawOutputNotes::new(Vec::new()).expect("empty output notes"),
587 Word::from([Felt::new_unchecked(seed), ZERO, ZERO, ZERO]),
588 )
589 }
590
591 fn proposal_delta(
592 account_id: &str,
593 nonce: u64,
594 new_commitment: &str,
595 seed: u64,
596 ) -> DeltaObject {
597 let payload = serde_json::json!({
598 "tx_summary": create_test_tx_summary(account_id, seed).to_json(),
599 "signatures": [],
600 "metadata": {
601 "proposal_type": "switch_guardian",
602 "required_signatures": 1,
603 "new_guardian_pubkey": "0x0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20",
604 "new_guardian_endpoint": "http://new-guardian.example.com"
605 }
606 });
607
608 DeltaObject {
609 account_id: account_id.to_string(),
610 nonce,
611 prev_commitment: "0x000".to_string(),
612 delta_payload: serde_json::to_string(&payload).expect("payload serialization"),
613 new_commitment: new_commitment.to_string(),
614 ack_sig: String::new(),
615 ack_pubkey: None,
616 ack_scheme: None,
617 candidate_at: String::new(),
618 canonical_at: None,
619 discarded_at: None,
620 status: None,
621 }
622 }
623
624 #[test]
625 fn inline_iteration_selects_by_unique_id_when_nonce_collides() {
626 let same_nonce = 42;
627 let delta_a = proposal_delta("0x7b7b7b7a7b7b7b017b7b7b7b7b7b7b", same_nonce, "0xaaa", 1);
628 let delta_b = proposal_delta("0x7c7c7c7c7c7c7c017c7c7c7c7c7c7c", same_nonce, "0xbbb", 2);
629
630 let target = Proposal::from(&delta_b).expect("proposal parses");
631
632 let proposals = [delta_a, delta_b.clone()];
633 let mut matched: Option<(&DeltaObject, Proposal)> = None;
634 for raw_proposal in &proposals {
635 let parsed = Proposal::from(raw_proposal).expect("parses");
636 if parsed.id == target.id {
637 matched = Some((raw_proposal, parsed));
638 }
639 }
640 let (raw, parsed) = matched.expect("proposal should be found");
641
642 assert_eq!(parsed.id, target.id);
643 assert_eq!(parsed.nonce, same_nonce);
644 assert_eq!(raw.new_commitment, delta_b.new_commitment);
645 }
646
647 #[test]
648 fn inline_iteration_rejects_duplicate_ids() {
649 let delta = proposal_delta("0x7b7b7b7a7b7b7b017b7b7b7b7b7b7b", 42, "0xaaa", 1);
650 let proposal_id = Proposal::from(&delta).expect("proposal parses").id;
651
652 let mut matched: Option<(&DeltaObject, Proposal)> = None;
653 let err = (&[delta.clone(), delta] as &[DeltaObject])
654 .iter()
655 .try_for_each(|raw_proposal| -> Result<()> {
656 let parsed = Proposal::from(raw_proposal)?;
657 if parsed.id == proposal_id {
658 if matched.is_some() {
659 return Err(MultisigError::InvalidConfig(format!(
660 "multiple proposals returned with the same ID {}",
661 proposal_id
662 )));
663 }
664 matched = Some((raw_proposal, parsed));
665 }
666 Ok(())
667 })
668 .expect_err("duplicate ids should fail");
669
670 match err {
671 MultisigError::InvalidConfig(message) => {
672 assert!(message.contains("multiple proposals returned with the same ID"));
673 }
674 other => panic!("unexpected error: {other:?}"),
675 }
676 }
677}