Skip to main content

miden_multisig_client/client/
proposals.rs

1//! Proposal workflow operations for MultisigClient.
2//!
3//! This module handles listing, signing, executing, and creating proposals
4//! via GUARDIAN (online mode).
5
6use std::collections::HashSet;
7
8use guardian_shared::{ProposalSignature, ToJson};
9use miden_client::transaction::TransactionRequest;
10
11use super::{MultisigClient, ProposalResult};
12
13/// Immediate outcome of an abandon request (issue #319).
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum AbandonRequestState {
16    /// The intent is recorded; GUARDIAN's worker resolves it after the
17    /// abandon quarantine. Poll [`MultisigClient::abandon_status`].
18    Pending,
19    /// The abandon was already resolved; the account is released.
20    Abandoned,
21    /// GUARDIAN had already stopped verifying the candidate and released
22    /// the account slot. Unlocked, but the on-chain outcome is still
23    /// uncertain: background reconciliation may promote the delta to
24    /// canonical until its retention TTL expires. Sync and check the
25    /// chain before replacing it.
26    Retained,
27}
28
29/// Resolution of an abandon request, as observed via the delta feed.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum AbandonStatus {
32    /// The delta is still a candidate; the quarantine is running.
33    Waiting,
34    /// The transaction landed after all; the delta canonicalized.
35    Landed,
36    /// The abandon completed; the delta is discarded as client-abandoned
37    /// and the account is released.
38    Abandoned,
39    /// GUARDIAN stopped verifying and released the account slot, but the
40    /// on-chain outcome is still uncertain — "unlocked but unresolved",
41    /// never to be read as "the transaction did not land". Background
42    /// reconciliation may promote the delta to canonical until its
43    /// retention TTL expires; sync and check the chain before replacing
44    /// it.
45    Retained,
46    /// The delta is missing or in a state no abandon flow produces.
47    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    /// Lists pending proposals for the current account.
81    ///
82    /// Proposals whose nonce is not above the committed account nonce are
83    /// skipped: they have already been executed or superseded, but GUARDIAN may
84    /// still report them pending until canonicalization prunes them.
85    ///
86    /// # Errors
87    ///
88    /// Returns an error if any proposal from GUARDIAN cannot be parsed. This ensures
89    /// malformed GUARDIAN payloads are surfaced rather than silently dropped.
90    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    /// Signs a proposal with the user's key.
122    pub async fn sign_proposal(&mut self, proposal_id: &str) -> Result<Proposal> {
123        let account = self.require_account()?;
124
125        // Check if user is a cosigner
126        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        // Check if already signed
135        if proposal.has_signed(&self.key_manager.commitment_hex()) {
136            return Err(MultisigError::AlreadySigned);
137        }
138
139        // Sign the transaction summary commitment
140        let tx_commitment = proposal.tx_summary.to_commitment();
141        let signature_hex = self.key_manager.sign_word_hex(tx_commitment);
142
143        // Build the ProposalSignature
144        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        // Push signature to GUARDIAN
151        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    /// Executes a proposal when it has enough signatures.
169    ///
170    /// This will:
171    /// 1. Sync with the Miden network to get latest chain state
172    /// 2. Get the proposal and verify it has enough signatures
173    /// 3. Push delta to GUARDIAN to get acknowledgment signature
174    /// 4. Build the transaction with all cosigner signatures + GUARDIAN ack
175    /// 5. Execute the transaction on-chain
176    /// 6. Sync and update local account state
177    ///
178    /// A proposal whose nonce is not strictly above the committed account nonce
179    /// is rejected: it has already been executed or superseded, and re-executing
180    /// would double-apply the intent and corrupt the nonce/delta sequence
181    /// GUARDIAN tracks for canonicalization.
182    ///
183    /// `SwitchGuardian` carries no GUARDIAN ack in the transaction itself (the
184    /// switch happens later in `finalize_transaction`), but its delta is still
185    /// pushed to the pre-switch GUARDIAN so it canonicalizes like any other
186    /// proposal. That push is best-effort: an unreachable GUARDIAN must not block
187    /// the switch, so the ack and any error are discarded.
188    pub async fn execute_proposal(&mut self, proposal_id: &str) -> Result<()> {
189        // Sync with the network before executing to ensure we have latest state
190        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        // Verify proposal is ready (has enough signatures)
207        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        // Custom proposals (issue #266) have no per-type reconstruction recipe,
216        // so the SDK cannot build/submit them. The integration executes them
217        // with its own recipe using the advice from `prepare_custom_execution`.
218        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        // Deduplicate by signer commitment
240        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        // Build signature advice from cosigner signatures
244        // Important: Use CURRENT account signers for validation, not proposal's new signers.
245        // The on-chain MASM verifies signatures against the currently stored public keys.
246        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            // Get GUARDIAN ack signature and add to advice
256            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            // SwitchGuardian: push the delta to the pre-switch GUARDIAN so it
267            // canonicalizes there and the account is released (issue #305).
268            // Best-effort — an unreachable GUARDIAN must not block the switch —
269            // but the outcome must be observable: a silently lost push leaves
270            // the old GUARDIAN serving a released account (split-brain) with
271            // nothing in any log to diagnose it by.
272            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        // Build the final transaction request with all signatures
291        let salt = proposal.metadata.salt()?;
292
293        // For signer-update transactions, we must propagate parse errors for signer commitments
294        // rather than silently converting to None. This ensures malformed hex is diagnosed properly.
295        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        // Execute and finalize
319        self.finalize_transaction(account_id, final_tx_request, &proposal.transaction_type)
320            .await
321    }
322
323    /// Creates a proposal from a producer-built transaction the SDK does not
324    /// model (issue #266 producer API). `transaction_request_bytes` is a serialized
325    /// `TransactionRequest`; `proposal_type` is a free-form, non-empty label
326    /// that MUST NOT collide with a built-in type. The integration keeps its own
327    /// recipe to execute later via `prepare_custom_execution`.
328    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    /// Assembles the validated execution advice for a threshold-met custom
405    /// proposal (issue #266 producer API): the cosigner signatures and the
406    /// GUARDIAN acknowledgment, keyed for the transaction's advice map. The
407    /// integration injects this into its own rebuilt transaction request
408    /// (`request.advice_map_mut().extend(advice)`) and submits it via its own
409    /// Miden client.
410    ///
411    /// `transaction_request_bytes` (the serialized transaction request) is used only to verify,
412    /// before the acknowledgment is requested, that it reproduces the signed
413    /// proposal commitment. On a not-ready proposal or a binding mismatch this
414    /// fails before requesting the acknowledgment.
415    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    /// Submits an integration-built transaction on-chain (issue #266 producer
494    /// API). The caller injects the advice from `prepare_custom_execution` into
495    /// its own transaction request (`request.advice_map_mut().extend(advice)`)
496    /// and passes it here to finalize.
497    pub async fn submit_transaction(&mut self, request: TransactionRequest) -> Result<()> {
498        // Refresh local state first: the account may have advanced between
499        // `prepare_custom_execution` and submit, and submitting against stale
500        // state would reject an otherwise-valid request.
501        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    /// Creates a proposal for a transaction.
517    ///
518    /// This is the primary API for creating multisig transaction proposals.
519    /// It handles all transaction types through a unified interface.
520    ///
521    /// # Example
522    ///
523    /// ```ignore
524    /// use miden_multisig_client::TransactionType;
525    ///
526    /// // Add a new cosigner
527    /// let proposal = client.propose_transaction(
528    ///     TransactionType::AddCosigner { new_commitment }
529    /// ).await?;
530    ///
531    /// // Remove a cosigner
532    /// let proposal = client.propose_transaction(
533    ///     TransactionType::RemoveCosigner { commitment }
534    /// ).await?;
535    /// ```
536    pub async fn propose_transaction(
537        &mut self,
538        transaction_type: TransactionType,
539    ) -> Result<Proposal> {
540        // Sync with the network before executing transaction
541        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    /// Proposes a transaction with automatic fallback to offline mode.
557    ///
558    /// First attempts to create the proposal via GUARDIAN. If GUARDIAN is unavailable
559    /// (connection error), falls back to offline proposal creation only when
560    /// the transaction supports GUARDIAN-less execution (`SwitchGuardian`).
561    ///
562    /// This is useful when you want to attempt online coordination but have a
563    /// graceful fallback path for offline sharing.
564    ///
565    /// # Returns
566    ///
567    /// - `ProposalResult::Online(Proposal)` if GUARDIAN succeeded
568    /// - `ProposalResult::Offline(ExportedProposal)` if GUARDIAN failed and transaction is `SwitchGuardian`
569    ///
570    /// # Example
571    ///
572    /// ```ignore
573    /// use miden_multisig_client::{TransactionType, ProposalResult};
574    ///
575    /// let tx = TransactionType::switch_guardian("https://new-guardian.example.com", new_guardian_commitment);
576    /// let result = client.propose_with_fallback(
577    ///     tx
578    /// ).await?;
579    ///
580    /// match result {
581    ///     ProposalResult::Online(proposal) => {
582    ///         println!("Proposal {} created on GUARDIAN", proposal.id);
583    ///     }
584    ///     ProposalResult::Offline(exported) => {
585    ///         println!("GUARDIAN unavailable, share this file with cosigners:");
586    ///         std::fs::write("proposal.json", exported.to_json()?)?;
587    ///     }
588    /// }
589    /// ```
590    pub async fn propose_with_fallback(
591        &mut self,
592        transaction_type: TransactionType,
593    ) -> Result<ProposalResult> {
594        // Try online first
595        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    /// Requests abandonment of a pending canonicalization candidate whose
612    /// transaction will never land on-chain (issue #319).
613    ///
614    /// Call this after an approved transaction died client-side (RPC
615    /// submit failure, prover timeout, crash). The request records an
616    /// abandon *intent* on GUARDIAN; the account stays locked until the
617    /// guardian's canonicalization worker confirms over a short
618    /// quarantine (typically well under a minute) that the transaction
619    /// did not land, then releases the account. Poll [`Self::abandon_status`]
620    /// for the resolution.
621    ///
622    /// `nonce` pins the exact candidate to release; it is the nonce the
623    /// proposal was pushed with (committed account nonce + 1). Retries
624    /// are idempotent and preserve the original request timestamp.
625    ///
626    /// # Errors
627    ///
628    /// Returns an error if the candidate's transaction actually landed
629    /// on-chain (`GUARDIAN_CANDIDATE_LANDED` — the server will
630    /// canonicalize it shortly), if no candidate exists at this nonce,
631    /// or if GUARDIAN cannot be reached.
632    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    /// Polls GUARDIAN for the resolution of an abandon request made with
651    /// [`Self::abandon_candidate`].
652    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            // A missing delta is unexpected for an abandon in flight: the
659            // worker preserves an abandoned delta as discarded history.
660            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
682/// Maps a delta's wire status onto the abandon lifecycle.
683fn 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        // A retained delta no longer holds the account's candidate slot,
692        // but its on-chain outcome is still uncertain: distinct from
693        // `Abandoned`, which would wrongly imply the transaction
694        // definitively did not land.
695        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        // A retained delta has released the account but its outcome is
846        // still uncertain: "unlocked but unresolved", never `Abandoned`.
847        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}