1use std::collections::HashSet;
7
8use guardian_shared::{ProposalSignature, ToJson};
9use miden_client::transaction::TransactionRequest;
10
11use super::{MultisigClient, ProposalResult};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum AbandonRequestState {
16 Pending,
19 Abandoned,
21 Retained,
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum AbandonStatus {
32 Waiting,
34 Landed,
36 Abandoned,
39 Retained,
46 Unexpected,
48}
49use crate::error::{MultisigError, Result};
50use crate::execution::{
51 SignatureAdvice, SignatureInput, build_final_transaction_request, collect_signature_advice,
52};
53use crate::keystore::proposal_public_key_hex;
54use crate::proposal::{Proposal, TransactionType, is_builtin_proposal_type};
55use crate::transaction::{
56 ProposalBuilder, deserialize_transaction_request, execute_for_summary, word_to_hex,
57};
58
59impl MultisigClient {
60 async fn get_proposal(
61 &mut self,
62 account_id: &miden_protocol::account::AccountId,
63 proposal_id: &str,
64 ) -> Result<Proposal> {
65 let mut guardian_client = self.create_authenticated_guardian_client().await?;
66 let response = guardian_client
67 .get_delta_proposal(account_id, proposal_id)
68 .await
69 .map_err(|e| MultisigError::GuardianServer(format!("failed to get proposal: {}", e)))?;
70
71 let raw_proposal = response
72 .proposal
73 .ok_or_else(|| MultisigError::ProposalNotFound(proposal_id.to_string()))?;
74 Self::ensure_proposal_account_id(&raw_proposal.account_id, account_id)?;
75 let proposal = Proposal::from(&raw_proposal)?;
76 self.verify_proposal_summary_binding(&proposal).await?;
77 Ok(proposal)
78 }
79
80 pub async fn list_proposals(&mut self) -> Result<Vec<Proposal>> {
91 let (account_id, current_nonce) = {
92 let account = self.require_account()?;
93 (account.id(), account.nonce())
94 };
95
96 let mut guardian_client = self.create_authenticated_guardian_client().await?;
97
98 let response = guardian_client
99 .get_delta_proposals(&account_id)
100 .await
101 .map_err(|e| {
102 MultisigError::GuardianServer(format!("failed to get proposals: {}", e))
103 })?;
104
105 let mut proposals = Vec::with_capacity(response.proposals.len());
106 for delta in &response.proposals {
107 Self::ensure_proposal_account_id(&delta.account_id, &account_id)?;
108 let proposal = Proposal::from(delta)?;
109
110 if proposal.nonce <= current_nonce {
111 continue;
112 }
113
114 self.verify_proposal_summary_binding(&proposal).await?;
115 proposals.push(proposal);
116 }
117
118 Ok(proposals)
119 }
120
121 pub async fn sign_proposal(&mut self, proposal_id: &str) -> Result<Proposal> {
123 let account = self.require_account()?;
124
125 let user_commitment = self.key_manager.commitment();
127 if !account.is_cosigner(&user_commitment) {
128 return Err(MultisigError::NotCosigner);
129 }
130
131 let account_id = account.id();
132 let proposal = self.get_proposal(&account_id, proposal_id).await?;
133
134 if proposal.has_signed(&self.key_manager.commitment_hex()) {
136 return Err(MultisigError::AlreadySigned);
137 }
138
139 let tx_commitment = proposal.tx_summary.to_commitment();
141 let signature_hex = self.key_manager.sign_word_hex(tx_commitment);
142
143 let signature = ProposalSignature::from_scheme(
145 self.key_manager.scheme(),
146 signature_hex,
147 proposal_public_key_hex(self.key_manager.as_ref()),
148 );
149
150 let mut guardian_client = self.create_authenticated_guardian_client().await?;
152 let sign_response = guardian_client
153 .sign_delta_proposal(&account_id, proposal_id, signature)
154 .await
155 .map_err(|e| {
156 MultisigError::GuardianServer(format!("failed to sign proposal: {}", e))
157 })?;
158
159 let updated_raw = sign_response
160 .delta
161 .as_ref()
162 .ok_or_else(|| MultisigError::ProposalNotFound(proposal_id.to_string()))?;
163 Self::ensure_proposal_account_id(&updated_raw.account_id, &account_id)?;
164 let updated = Proposal::from(updated_raw)?;
165 Ok(updated)
166 }
167
168 pub async fn execute_proposal(&mut self, proposal_id: &str) -> Result<()> {
189 self.sync().await?;
191
192 let account = self.require_account()?.clone();
193 let account_id = account.id();
194
195 let proposal = self.get_proposal(&account_id, proposal_id).await?;
196
197 if proposal.nonce <= account.nonce() {
198 return Err(MultisigError::InvalidConfig(format!(
199 "proposal nonce {} is not greater than the current account nonce {}; \
200 it has already been executed or superseded",
201 proposal.nonce,
202 account.nonce()
203 )));
204 }
205
206 if !proposal.status.is_ready() {
208 let (collected, required) = proposal.signature_counts();
209 return Err(MultisigError::ProposalNotReady {
210 collected,
211 required,
212 });
213 }
214
215 if matches!(proposal.transaction_type, TransactionType::Custom) {
219 return Err(MultisigError::UnsupportedTransactionType(
220 "custom proposals are executed by the integration; call \
221 prepare_custom_execution to get the cosigner + GUARDIAN advice"
222 .to_string(),
223 ));
224 }
225
226 let tx_summary_commitment = proposal.tx_summary.to_commitment();
227
228 let mut signature_inputs: Vec<SignatureInput> = proposal
229 .signatures
230 .into_iter()
231 .map(|signature| SignatureInput {
232 signer_commitment: signature.signer_commitment,
233 signature_hex: signature.signature_hex,
234 scheme: signature.scheme,
235 public_key_hex: signature.public_key_hex,
236 })
237 .collect();
238
239 signature_inputs.sort_by(|a, b| a.signer_commitment.cmp(&b.signer_commitment));
241 signature_inputs.dedup_by(|a, b| a.signer_commitment == b.signer_commitment);
242
243 let required_commitments: HashSet<String> =
247 account.cosigner_commitments_hex().into_iter().collect();
248 let mut signature_advice = collect_signature_advice(
249 signature_inputs,
250 &required_commitments,
251 tx_summary_commitment,
252 )?;
253
254 if proposal.transaction_type.requires_guardian_ack() {
255 let guardian_advice = self
257 .get_guardian_ack_signature(
258 &account,
259 proposal.nonce,
260 &proposal.tx_summary,
261 tx_summary_commitment,
262 )
263 .await?;
264 signature_advice.push(guardian_advice);
265 } else {
266 if let Err(error) = self
273 .get_guardian_ack_signature(
274 &account,
275 proposal.nonce,
276 &proposal.tx_summary,
277 tx_summary_commitment,
278 )
279 .await
280 {
281 tracing::warn!(
282 %error,
283 "best-effort SwitchGuardian delta push to the pre-switch \
284 GUARDIAN failed; it will keep serving this account until \
285 reconciliation"
286 );
287 }
288 }
289
290 let salt = proposal.metadata.salt()?;
292
293 let signer_commitments = if matches!(
296 &proposal.transaction_type,
297 TransactionType::AddCosigner { .. }
298 | TransactionType::RemoveCosigner { .. }
299 | TransactionType::UpdateSigners { .. }
300 ) {
301 Some(proposal.metadata.signer_commitments()?)
302 } else {
303 proposal.metadata.signer_commitments().ok()
304 };
305
306 let final_tx_request = build_final_transaction_request(
307 &self.miden_client,
308 &proposal.transaction_type,
309 account.inner(),
310 salt,
311 signature_advice,
312 proposal.metadata.new_threshold,
313 signer_commitments.as_deref(),
314 self.key_manager.scheme(),
315 )
316 .await?;
317
318 self.finalize_transaction(account_id, final_tx_request, &proposal.transaction_type)
320 .await
321 }
322
323 pub async fn propose_custom_transaction(
329 &mut self,
330 transaction_request_bytes: &[u8],
331 proposal_type: &str,
332 ) -> Result<Proposal> {
333 let proposal_type = proposal_type.trim().to_lowercase();
334 if proposal_type.is_empty() {
335 return Err(MultisigError::InvalidConfig(
336 "proposal_type must not be empty".to_string(),
337 ));
338 }
339 if !proposal_type
340 .bytes()
341 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')
342 {
343 return Err(MultisigError::InvalidConfig(format!(
344 "proposal_type '{}' must be lowercase snake_case ([a-z0-9_]): no spaces, hyphens, or other characters",
345 proposal_type
346 )));
347 }
348 if is_builtin_proposal_type(&proposal_type) {
349 return Err(MultisigError::UnsupportedTransactionType(format!(
350 "'{}' is a built-in proposal type; use the typed proposal API instead",
351 proposal_type
352 )));
353 }
354
355 self.sync().await?;
356 let account = self.require_account()?.clone();
357 let account_id = account.id();
358
359 let tx_request = deserialize_transaction_request(transaction_request_bytes)?;
360 let tx_summary =
361 execute_for_summary(&mut self.miden_client, account_id, tx_request).await?;
362 let tx_commitment = tx_summary.to_commitment();
363
364 let required_signatures = account.threshold()? as usize;
365
366 let metadata = crate::proposal::ProposalMetadata {
367 tx_summary_json: Some(tx_summary.to_json()),
368 proposal_type: Some(proposal_type.to_string()),
369 required_signatures: Some(required_signatures),
370 signers: vec![self.key_manager.commitment_hex()],
371 ..Default::default()
372 };
373
374 let payload = crate::payload::ProposalPayload::new(&tx_summary)
375 .with_signature(self.key_manager.as_ref(), tx_commitment)
376 .with_custom_metadata(proposal_type.to_string())
377 .with_required_signatures(required_signatures);
378
379 let nonce = account.nonce() + 1;
380 let mut guardian_client = self.create_authenticated_guardian_client().await?;
381 let response = guardian_client
382 .push_delta_proposal(&account_id, nonce, &payload.to_json())
383 .await
384 .map_err(|e| {
385 MultisigError::GuardianServer(format!("failed to push proposal: {}", e))
386 })?;
387
388 let proposal = Proposal::new(tx_summary, nonce, TransactionType::Custom, metadata);
389
390 if !proposal
391 .id
392 .trim_start_matches("0x")
393 .eq_ignore_ascii_case(response.commitment.trim_start_matches("0x"))
394 {
395 return Err(MultisigError::GuardianServer(format!(
396 "GUARDIAN returned proposal commitment {} but expected {}",
397 response.commitment, proposal.id
398 )));
399 }
400
401 Ok(proposal)
402 }
403
404 pub async fn prepare_custom_execution(
416 &mut self,
417 proposal_id: &str,
418 transaction_request_bytes: &[u8],
419 ) -> Result<Vec<SignatureAdvice>> {
420 self.sync().await?;
421 let account = self.require_account()?.clone();
422 let account_id = account.id();
423
424 let proposal = self.get_proposal(&account_id, proposal_id).await?;
425
426 if !matches!(proposal.transaction_type, TransactionType::Custom) {
427 return Err(MultisigError::UnsupportedTransactionType(
428 "prepare_custom_execution is only for custom proposals; use execute_proposal \
429 for built-in types"
430 .to_string(),
431 ));
432 }
433
434 if !proposal.status.is_ready() {
435 let (collected, required) = proposal.signature_counts();
436 return Err(MultisigError::ProposalNotReady {
437 collected,
438 required,
439 });
440 }
441
442 let tx_summary_commitment = proposal.tx_summary.to_commitment();
443
444 let probe_request = deserialize_transaction_request(transaction_request_bytes)?;
445 let derived_summary =
446 execute_for_summary(&mut self.miden_client, account_id, probe_request).await?;
447 let derived_commitment = derived_summary.to_commitment();
448 if derived_commitment != tx_summary_commitment {
449 return Err(MultisigError::InvalidConfig(format!(
450 "transaction request does not match the signed proposal commitment \
451 (expected {}, got {})",
452 word_to_hex(&tx_summary_commitment),
453 word_to_hex(&derived_commitment)
454 )));
455 }
456
457 let mut signature_inputs: Vec<SignatureInput> = proposal
458 .signatures
459 .into_iter()
460 .map(|signature| SignatureInput {
461 signer_commitment: signature.signer_commitment,
462 signature_hex: signature.signature_hex,
463 scheme: signature.scheme,
464 public_key_hex: signature.public_key_hex,
465 })
466 .collect();
467 signature_inputs.sort_by(|a, b| a.signer_commitment.cmp(&b.signer_commitment));
468 signature_inputs.dedup_by(|a, b| a.signer_commitment == b.signer_commitment);
469
470 let required_commitments: HashSet<String> =
471 account.cosigner_commitments_hex().into_iter().collect();
472 let mut signature_advice = collect_signature_advice(
473 signature_inputs,
474 &required_commitments,
475 tx_summary_commitment,
476 )?;
477
478 if proposal.transaction_type.requires_guardian_ack() {
479 let guardian_advice = self
480 .get_guardian_ack_signature(
481 &account,
482 proposal.nonce,
483 &derived_summary,
484 tx_summary_commitment,
485 )
486 .await?;
487 signature_advice.push(guardian_advice);
488 }
489
490 Ok(signature_advice)
491 }
492
493 pub async fn submit_transaction(&mut self, request: TransactionRequest) -> Result<()> {
498 self.sync().await?;
502 let account_id = self.require_account()?.id();
503 self.miden_client
504 .submit_new_transaction(account_id, request)
505 .await
506 .map_err(|e| {
507 MultisigError::TransactionExecution(format!(
508 "transaction submission failed: {:?}",
509 e
510 ))
511 })?;
512 let _ = self.miden_client.sync_state().await;
513 Ok(())
514 }
515
516 pub async fn propose_transaction(
537 &mut self,
538 transaction_type: TransactionType,
539 ) -> Result<Proposal> {
540 self.sync().await?;
542
543 let account = self.require_account()?.clone();
544 let mut guardian_client = self.create_authenticated_guardian_client().await?;
545
546 ProposalBuilder::new(transaction_type)
547 .build(
548 &mut self.miden_client,
549 &mut guardian_client,
550 &account,
551 self.key_manager.as_ref(),
552 )
553 .await
554 }
555
556 pub async fn propose_with_fallback(
591 &mut self,
592 transaction_type: TransactionType,
593 ) -> Result<ProposalResult> {
594 match self.propose_transaction(transaction_type.clone()).await {
596 Ok(proposal) => Ok(ProposalResult::Online(Box::new(proposal))),
597 Err(
598 error @ (MultisigError::GuardianConnection(_) | MultisigError::GuardianServer(_)),
599 ) => {
600 if transaction_type.supports_offline_execution() {
601 let exported = self.create_proposal_offline(transaction_type).await?;
602 Ok(ProposalResult::Offline(Box::new(exported)))
603 } else {
604 Err(error)
605 }
606 }
607 Err(e) => Err(e),
608 }
609 }
610
611 pub async fn abandon_candidate(&mut self, nonce: u64) -> Result<AbandonRequestState> {
633 let account_id = self.require_account()?.id();
634
635 let mut guardian_client = self.create_authenticated_guardian_client().await?;
636 let response = guardian_client
637 .abandon_candidate(&account_id, nonce)
638 .await
639 .map_err(|e| {
640 MultisigError::GuardianServer(format!("failed to abandon candidate: {}", e))
641 })?;
642
643 Ok(match response.state.as_str() {
644 "abandoned" => AbandonRequestState::Abandoned,
645 "retained" => AbandonRequestState::Retained,
646 _ => AbandonRequestState::Pending,
647 })
648 }
649
650 pub async fn abandon_status(&mut self, nonce: u64) -> Result<AbandonStatus> {
653 let account_id = self.require_account()?.id();
654
655 let mut guardian_client = self.create_authenticated_guardian_client().await?;
656 let response = match guardian_client.get_delta(&account_id, nonce).await {
657 Ok(response) => response,
658 Err(e) if e.to_string().to_lowercase().contains("not found") => {
661 return Ok(AbandonStatus::Unexpected);
662 }
663 Err(e) => {
664 return Err(MultisigError::GuardianServer(format!(
665 "failed to poll abandon status: {}",
666 e
667 )));
668 }
669 };
670
671 let Some(delta) = response.delta else {
672 return Ok(AbandonStatus::Unexpected);
673 };
674 let Some(status) = delta.status else {
675 return Ok(AbandonStatus::Unexpected);
676 };
677
678 Ok(classify_abandon_status(&status))
679 }
680}
681
682fn classify_abandon_status(status: &guardian_client::DeltaStatus) -> AbandonStatus {
684 use guardian_client::delta_status::Status as ProtoStatus;
685 match status.status {
686 Some(ProtoStatus::CandidateAt(_)) => AbandonStatus::Waiting,
687 Some(ProtoStatus::CanonicalAt(_)) => AbandonStatus::Landed,
688 Some(ProtoStatus::DiscardedAt(_)) if status.discard_reason == "client_abandoned" => {
689 AbandonStatus::Abandoned
690 }
691 Some(ProtoStatus::RetainedAt(_)) => AbandonStatus::Retained,
696 _ => AbandonStatus::Unexpected,
697 }
698}
699
700#[cfg(test)]
701mod tests {
702 use guardian_client::DeltaObject;
703 use guardian_shared::ToJson;
704 use miden_protocol::account::AccountId;
705 use miden_protocol::account::delta::{AccountDelta, AccountStorageDelta, AccountVaultDelta};
706 use miden_protocol::transaction::{InputNotes, RawOutputNotes, TransactionSummary};
707 use miden_protocol::{Felt, Word, ZERO};
708
709 use super::{AbandonStatus, classify_abandon_status};
710 use crate::error::{MultisigError, Result};
711 use crate::proposal::Proposal;
712
713 fn create_test_tx_summary(account_id: &str, seed: u64) -> TransactionSummary {
714 let account_id = AccountId::from_hex(account_id).expect("valid account id");
715 let account_delta = AccountDelta::new(
716 account_id,
717 AccountStorageDelta::default(),
718 AccountVaultDelta::default(),
719 Felt::ZERO,
720 )
721 .expect("valid delta");
722
723 TransactionSummary::new(
724 account_delta,
725 InputNotes::new(Vec::new()).expect("empty input notes"),
726 RawOutputNotes::new(Vec::new()).expect("empty output notes"),
727 Word::from([Felt::new_unchecked(seed), ZERO, ZERO, ZERO]),
728 )
729 }
730
731 fn proposal_delta(
732 account_id: &str,
733 nonce: u64,
734 new_commitment: &str,
735 seed: u64,
736 ) -> DeltaObject {
737 let payload = serde_json::json!({
738 "tx_summary": create_test_tx_summary(account_id, seed).to_json(),
739 "signatures": [],
740 "metadata": {
741 "proposal_type": "switch_guardian",
742 "required_signatures": 1,
743 "new_guardian_pubkey": "0x0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20",
744 "new_guardian_endpoint": "http://new-guardian.example.com"
745 }
746 });
747
748 DeltaObject {
749 account_id: account_id.to_string(),
750 nonce,
751 prev_commitment: "0x000".to_string(),
752 delta_payload: serde_json::to_string(&payload).expect("payload serialization"),
753 new_commitment: new_commitment.to_string(),
754 ack_sig: String::new(),
755 ack_pubkey: None,
756 ack_scheme: None,
757 candidate_at: String::new(),
758 canonical_at: None,
759 discarded_at: None,
760 status: None,
761 }
762 }
763
764 #[test]
765 fn inline_iteration_selects_by_unique_id_when_nonce_collides() {
766 let same_nonce = 42;
767 let delta_a = proposal_delta("0x7b7b7b7a7b7b7b017b7b7b7b7b7b7b", same_nonce, "0xaaa", 1);
768 let delta_b = proposal_delta("0x7c7c7c7c7c7c7c017c7c7c7c7c7c7c", same_nonce, "0xbbb", 2);
769
770 let target = Proposal::from(&delta_b).expect("proposal parses");
771
772 let proposals = [delta_a, delta_b.clone()];
773 let mut matched: Option<(&DeltaObject, Proposal)> = None;
774 for raw_proposal in &proposals {
775 let parsed = Proposal::from(raw_proposal).expect("parses");
776 if parsed.id == target.id {
777 matched = Some((raw_proposal, parsed));
778 }
779 }
780 let (raw, parsed) = matched.expect("proposal should be found");
781
782 assert_eq!(parsed.id, target.id);
783 assert_eq!(parsed.nonce, same_nonce);
784 assert_eq!(raw.new_commitment, delta_b.new_commitment);
785 }
786
787 #[test]
788 fn inline_iteration_rejects_duplicate_ids() {
789 let delta = proposal_delta("0x7b7b7b7a7b7b7b017b7b7b7b7b7b7b", 42, "0xaaa", 1);
790 let proposal_id = Proposal::from(&delta).expect("proposal parses").id;
791
792 let mut matched: Option<(&DeltaObject, Proposal)> = None;
793 let err = (&[delta.clone(), delta] as &[DeltaObject])
794 .iter()
795 .try_for_each(|raw_proposal| -> Result<()> {
796 let parsed = Proposal::from(raw_proposal)?;
797 if parsed.id == proposal_id {
798 if matched.is_some() {
799 return Err(MultisigError::InvalidConfig(format!(
800 "multiple proposals returned with the same ID {}",
801 proposal_id
802 )));
803 }
804 matched = Some((raw_proposal, parsed));
805 }
806 Ok(())
807 })
808 .expect_err("duplicate ids should fail");
809
810 match err {
811 MultisigError::InvalidConfig(message) => {
812 assert!(message.contains("multiple proposals returned with the same ID"));
813 }
814 other => panic!("unexpected error: {other:?}"),
815 }
816 }
817
818 #[test]
819 fn abandon_status_classifies_every_wire_status() {
820 use guardian_client::DeltaStatus;
821 use guardian_client::delta_status::Status as ProtoStatus;
822
823 let status = |status: Option<ProtoStatus>, discard_reason: &str| DeltaStatus {
824 status,
825 discard_reason: discard_reason.to_string(),
826 retain_reason: String::new(),
827 };
828
829 let ts = "2026-07-28T00:00:00Z".to_string();
830 assert_eq!(
831 classify_abandon_status(&status(Some(ProtoStatus::CandidateAt(ts.clone())), "")),
832 AbandonStatus::Waiting
833 );
834 assert_eq!(
835 classify_abandon_status(&status(Some(ProtoStatus::CanonicalAt(ts.clone())), "")),
836 AbandonStatus::Landed
837 );
838 assert_eq!(
839 classify_abandon_status(&status(
840 Some(ProtoStatus::DiscardedAt(ts.clone())),
841 "client_abandoned"
842 )),
843 AbandonStatus::Abandoned
844 );
845 assert_eq!(
848 classify_abandon_status(&status(Some(ProtoStatus::RetainedAt(ts.clone())), "")),
849 AbandonStatus::Retained
850 );
851 assert_eq!(
852 classify_abandon_status(&status(Some(ProtoStatus::DiscardedAt(ts)), "")),
853 AbandonStatus::Unexpected
854 );
855 assert_eq!(
856 classify_abandon_status(&status(None, "")),
857 AbandonStatus::Unexpected
858 );
859 }
860}