Skip to main content

miden_multisig_client/client/
offline.rs

1//! Offline proposal operations for MultisigClient.
2//!
3//! This module handles creating, signing, and executing proposals
4//! without GUARDIAN coordination (offline/side-channel mode).
5
6use std::collections::HashSet;
7
8use guardian_shared::ToJson;
9
10use super::MultisigClient;
11use crate::error::{MultisigError, Result};
12use crate::execution::{SignatureInput, build_final_transaction_request, collect_signature_advice};
13use crate::export::{EXPORT_VERSION, ExportedMetadata, ExportedProposal, ExportedSignature};
14use crate::guardian_endpoint::verify_endpoint_commitment;
15use crate::keystore::proposal_public_key_hex;
16use crate::proposal::TransactionType;
17
18impl MultisigClient {
19    /// Creates a proposal offline without pushing to GUARDIAN.
20    ///
21    /// Only `SwitchGuardian` transactions can be executed fully offline because
22    /// all other transaction types require a GUARDIAN acknowledgment signature.
23    ///
24    /// This returns an `ExportedProposal` that can be serialized to JSON and
25    /// shared with cosigners.
26    ///
27    /// The proposer's signature is automatically included in the exported proposal.
28    ///
29    /// # Example
30    ///
31    /// ```ignore
32    /// use miden_multisig_client::TransactionType;
33    ///
34    /// // Create proposal offline
35    /// let exported = client.create_proposal_offline(
36    ///     TransactionType::SwitchGuardian { new_endpoint, new_commitment }
37    /// ).await?;
38    ///
39    /// // Save to file for sharing
40    /// std::fs::write("proposal.json", exported.to_json()?)?;
41    /// ```
42    pub async fn create_proposal_offline(
43        &mut self,
44        transaction_type: TransactionType,
45    ) -> Result<ExportedProposal> {
46        self.sync_network_only().await?;
47
48        let account = self.require_account()?.clone();
49        let account_id = account.id();
50        let signatures_required =
51            account.effective_threshold_for_transaction(&transaction_type)? as usize;
52
53        let salt = crate::transaction::generate_salt();
54        let (new_endpoint, new_commitment) = match &transaction_type {
55            TransactionType::SwitchGuardian {
56                new_endpoint,
57                new_commitment,
58            } => {
59                verify_endpoint_commitment(new_endpoint, *new_commitment).await?;
60                (new_endpoint.clone(), *new_commitment)
61            }
62            _ => {
63                return Err(MultisigError::OfflineUnsupportedTransaction(
64                    transaction_type.type_name().to_string(),
65                ));
66            }
67        };
68
69        let tx_request = crate::transaction::build_update_guardian_transaction_request(
70            new_commitment,
71            salt,
72            std::iter::empty(),
73        )?;
74        let metadata = ExportedMetadata {
75            proposal_type: "switch_guardian".to_string(),
76            salt_hex: Some(crate::transaction::word_to_hex(&salt)),
77            new_guardian_pubkey_hex: Some(crate::transaction::word_to_hex(&new_commitment)),
78            new_guardian_endpoint: Some(new_endpoint),
79            ..Default::default()
80        };
81
82        let tx_summary =
83            crate::transaction::execute_for_summary(&mut self.miden_client, account_id, tx_request)
84                .await?;
85
86        let tx_commitment = tx_summary.to_commitment();
87        let signature_hex = self.key_manager.sign_word_hex(tx_commitment);
88
89        let id = crate::transaction::word_to_hex(&tx_commitment);
90
91        let exported = ExportedProposal {
92            version: EXPORT_VERSION,
93            account_id: account_id.to_string(),
94            id,
95            nonce: account.nonce() + 1,
96            tx_summary: tx_summary.to_json(),
97            signatures: vec![ExportedSignature {
98                signer_commitment: self.key_manager.commitment_hex(),
99                signature: signature_hex,
100                scheme: self.key_manager.scheme(),
101                public_key_hex: proposal_public_key_hex(self.key_manager.as_ref()),
102            }],
103            signatures_required,
104            metadata,
105        };
106
107        Ok(exported)
108    }
109
110    /// Signs an imported proposal locally (without GUARDIAN).
111    ///
112    /// The signature is added directly to the proposal. After signing,
113    /// export the proposal again to share with other cosigners.
114    ///
115    /// Only `SwitchGuardian` proposals are supported in this mode.
116    ///
117    /// # Example
118    ///
119    /// ```ignore
120    /// let mut proposal = client.import_proposal("/tmp/proposal.json").await?;
121    /// client.sign_imported_proposal(&mut proposal).await?;
122    /// let json = proposal.to_json()?;
123    /// std::fs::write("/tmp/proposal_signed.json", json)?;
124    /// ```
125    pub async fn sign_imported_proposal(&mut self, proposal: &mut ExportedProposal) -> Result<()> {
126        let bound_proposal = proposal.to_proposal()?;
127        if !bound_proposal.transaction_type.supports_offline_execution() {
128            return Err(MultisigError::OfflineUnsupportedTransaction(
129                bound_proposal.transaction_type.type_name().to_string(),
130            ));
131        }
132        self.verify_proposal_summary_binding(&bound_proposal)
133            .await?;
134        let account = self.require_account()?;
135        let account_id = account.id();
136        proposal.validate(Some(account_id))?;
137
138        // Check if user is a cosigner
139        let user_commitment = self.key_manager.commitment();
140        if !account.is_cosigner(&user_commitment) {
141            return Err(MultisigError::NotCosigner);
142        }
143
144        Self::ensure_proposal_account_id(&proposal.account_id, &account_id)?;
145
146        // Check if already signed
147        let user_commitment_hex = self.key_manager.commitment_hex();
148        if proposal.signatures.iter().any(|s| {
149            s.signer_commitment
150                .eq_ignore_ascii_case(&user_commitment_hex)
151        }) {
152            return Err(MultisigError::AlreadySigned);
153        }
154        // Sign the transaction summary commitment
155        let tx_commitment = bound_proposal.tx_summary.to_commitment();
156        let signature_hex = self.key_manager.sign_word_hex(tx_commitment);
157
158        // Add signature to proposal
159        proposal.add_signature(ExportedSignature {
160            signer_commitment: user_commitment_hex,
161            signature: signature_hex,
162            scheme: self.key_manager.scheme(),
163            public_key_hex: proposal_public_key_hex(self.key_manager.as_ref()),
164        })?;
165
166        Ok(())
167    }
168
169    /// Executes an imported proposal (with all signatures already collected).
170    ///
171    /// This builds and submits the transaction directly to the Miden network
172    /// without contacting GUARDIAN.
173    ///
174    /// Only `SwitchGuardian` transactions are supported in this mode.
175    ///
176    /// # Example
177    ///
178    /// ```ignore
179    /// let proposal = client.import_proposal("/tmp/proposal_final.json").await?;
180    /// client.execute_imported_proposal(&proposal).await?;
181    /// ```
182    pub async fn execute_imported_proposal(&mut self, exported: &ExportedProposal) -> Result<()> {
183        self.sync_network_only().await?;
184        let account = self.require_account()?.clone();
185        let account_id = account.id();
186        exported.validate(Some(account_id))?;
187
188        // Verify proposal is ready
189        if !exported.is_ready() {
190            return Err(MultisigError::ProposalNotReady {
191                collected: exported.signatures_collected(),
192                required: exported.signatures_required,
193            });
194        }
195
196        // Parse the proposal
197        let proposal = exported.to_proposal()?;
198        if !proposal.transaction_type.supports_offline_execution() {
199            return Err(MultisigError::OfflineUnsupportedTransaction(
200                proposal.transaction_type.type_name().to_string(),
201            ));
202        }
203        self.verify_proposal_summary_binding(&proposal).await?;
204        let tx_summary = proposal.tx_summary.clone();
205        let tx_summary_commitment = tx_summary.to_commitment();
206
207        // Convert exported signatures to SignatureInput format
208        let signature_inputs: Vec<SignatureInput> = exported
209            .signatures
210            .iter()
211            .map(|sig| SignatureInput {
212                signer_commitment: sig.signer_commitment.clone(),
213                signature_hex: sig.signature.clone(),
214                scheme: sig.scheme,
215                public_key_hex: sig.public_key_hex.clone(),
216            })
217            .collect();
218
219        // Build signature advice from cosigner signatures
220        let required_commitments: HashSet<String> =
221            account.cosigner_commitments_hex().into_iter().collect();
222        let signature_advice = collect_signature_advice(
223            signature_inputs,
224            &required_commitments,
225            tx_summary_commitment,
226        )?;
227
228        // Build the final transaction request with all signatures
229        let salt = proposal.metadata.salt()?;
230
231        let final_tx_request = build_final_transaction_request(
232            &self.miden_client,
233            &proposal.transaction_type,
234            account.inner(),
235            salt,
236            signature_advice,
237            None,
238            None,
239            self.key_manager.scheme(),
240        )
241        .await?;
242
243        // Execute and finalize
244        self.finalize_transaction(account_id, final_tx_request, &proposal.transaction_type)
245            .await
246    }
247}