Skip to main content

miden_multisig_client/client/
account.rs

1//! Account lifecycle operations for MultisigClient.
2//!
3//! This module handles account creation, pulling/pushing from GUARDIAN,
4//! syncing, and registration operations.
5
6use std::collections::HashSet;
7
8use base64::Engine;
9use guardian_client::{
10    AuthConfig, ClientError as GuardianClientError, MidenEcdsaAuth, MidenFalconRpoAuth,
11    TryIntoTxSummary, auth_config::AuthType,
12};
13use guardian_shared::SignatureScheme;
14use miden_client::account::Account;
15use miden_client::{Deserializable, Serializable};
16use miden_confidential_contracts::multisig_guardian::{
17    MultisigGuardianBuilder, MultisigGuardianConfig,
18};
19use miden_protocol::Word;
20use miden_protocol::account::AccountId;
21
22use super::{MultisigClient, StateVerificationResult};
23use crate::account::MultisigAccount;
24use crate::error::{MultisigError, Result};
25use crate::keystore::word_from_hex;
26use crate::procedures::ProcedureThreshold;
27use crate::transaction::word_to_hex;
28
29impl MultisigClient {
30    fn ensure_unique_signer_commitments(signer_commitments: &[Word]) -> Result<()> {
31        let mut seen = HashSet::new();
32
33        for commitment in signer_commitments {
34            let commitment_hex = word_to_hex(commitment);
35            if !seen.insert(commitment_hex.clone()) {
36                return Err(MultisigError::InvalidConfig(format!(
37                    "duplicate signer commitment: {}",
38                    commitment_hex
39                )));
40            }
41        }
42
43        Ok(())
44    }
45
46    /// Creates a new multisig account.
47    ///
48    /// # Arguments
49    /// * `threshold` - Minimum number of signatures required (default threshold)
50    /// * `signer_commitments` - Public key commitments of all signers
51    ///
52    /// For per-procedure thresholds, use `create_account_with_proc_thresholds` instead.
53    pub async fn create_account(
54        &mut self,
55        threshold: u32,
56        signer_commitments: Vec<Word>,
57    ) -> Result<&MultisigAccount> {
58        self.create_account_with_proc_thresholds(threshold, signer_commitments, Vec::new())
59            .await
60    }
61
62    /// Creates a new multisig account with per-procedure threshold overrides.
63    ///
64    /// # Arguments
65    /// * `threshold` - Minimum number of signatures required (default threshold)
66    /// * `signer_commitments` - Public key commitments of all signers
67    /// * `proc_threshold_overrides` - Per-procedure threshold overrides using named procedures.
68    ///
69    /// # Example
70    ///
71    /// ```ignore
72    /// use miden_multisig_client::{ProcedureThreshold, ProcedureName};
73    ///
74    /// let thresholds = vec![
75    ///     ProcedureThreshold::new(ProcedureName::ReceiveAsset, 1),
76    ///     ProcedureThreshold::new(ProcedureName::UpdateSigners, 3),
77    /// ];
78    ///
79    /// let account = client.create_account_with_proc_thresholds(
80    ///     2,  // default 2-of-3
81    ///     signer_commitments,
82    ///     thresholds,
83    /// ).await?;
84    /// ```
85    pub async fn create_account_with_proc_thresholds(
86        &mut self,
87        threshold: u32,
88        signer_commitments: Vec<Word>,
89        proc_threshold_overrides: Vec<ProcedureThreshold>,
90    ) -> Result<&MultisigAccount> {
91        Self::ensure_unique_signer_commitments(&signer_commitments)?;
92        let signature_scheme = self.key_manager.scheme();
93
94        // Get GUARDIAN server's public key commitment
95        let mut guardian_client = self.create_guardian_client().await?;
96        let (guardian_commitment_hex, _raw_pubkey) = guardian_client
97            .get_pubkey(Some(&signature_scheme.to_string()))
98            .await
99            .map_err(|e| {
100                MultisigError::GuardianServer(format!("failed to get GUARDIAN pubkey: {}", e))
101            })?;
102
103        let guardian_commitment =
104            word_from_hex(&guardian_commitment_hex).map_err(MultisigError::HexDecode)?;
105
106        // Convert procedure thresholds to (Word, u32) pairs
107        let overrides: Vec<(Word, u32)> = proc_threshold_overrides
108            .iter()
109            .map(|pt| (pt.procedure_root(), pt.threshold))
110            .collect();
111
112        // Create the multisig account config
113        let guardian_config =
114            MultisigGuardianConfig::new(threshold, signer_commitments, guardian_commitment)
115                .with_signature_scheme(signature_scheme)
116                .with_proc_threshold_overrides(overrides);
117
118        // Generate a random seed for account ID
119        let mut seed = [0u8; 32];
120        rand::Rng::fill(&mut rand::rng(), &mut seed);
121
122        let account = MultisigGuardianBuilder::new(guardian_config)
123            .with_seed(seed)
124            .build()
125            .map_err(|e| MultisigError::MidenClient(format!("failed to build account: {}", e)))?;
126
127        // Add to miden-client
128        self.add_or_update_account(&account, false).await?;
129
130        // Wrap in MultisigAccount and store
131        let multisig_account = MultisigAccount::new(account);
132        self.account = Some(multisig_account);
133
134        Ok(self.account.as_ref().unwrap())
135    }
136
137    /// Pulls an account from GUARDIAN and loads it locally.
138    ///
139    /// Use this when joining an existing multisig as a cosigner.
140    pub async fn pull_account(&mut self, account_id: AccountId) -> Result<&MultisigAccount> {
141        let mut guardian_client = self.create_authenticated_guardian_client().await?;
142
143        let state_response = guardian_client
144            .get_state(&account_id)
145            .await
146            .map_err(|e| MultisigError::GuardianServer(format!("failed to get state: {}", e)))?;
147
148        let state_obj = state_response.state.ok_or_else(|| {
149            MultisigError::GuardianServer("no state returned from GUARDIAN".to_string())
150        })?;
151
152        let state_value: serde_json::Value = serde_json::from_str(&state_obj.state_json)?;
153
154        let account_base64 = state_value["data"].as_str().ok_or_else(|| {
155            MultisigError::GuardianServer("missing 'data' field in state".to_string())
156        })?;
157
158        let account_bytes = base64::engine::general_purpose::STANDARD
159            .decode(account_base64)
160            .map_err(|e| MultisigError::MidenClient(format!("failed to decode account: {}", e)))?;
161
162        let account = Account::read_from_bytes(&account_bytes).map_err(|e| {
163            MultisigError::MidenClient(format!("failed to deserialize account: {}", e))
164        })?;
165
166        self.add_or_update_account(&account, true).await?;
167
168        let multisig_account = MultisigAccount::new(account);
169        self.account = Some(multisig_account);
170
171        Ok(self.account.as_ref().unwrap())
172    }
173
174    /// Pushes the current account to GUARDIAN for initial registration.
175    pub async fn push_account(&mut self) -> Result<()> {
176        let account = self
177            .account
178            .as_ref()
179            .ok_or_else(|| MultisigError::MissingConfig("no account loaded".to_string()))?;
180
181        let mut guardian_client = self.create_authenticated_guardian_client().await?;
182
183        let account_bytes = account.inner().to_bytes();
184        let account_base64 = base64::engine::general_purpose::STANDARD.encode(&account_bytes);
185
186        let initial_state = serde_json::json!({
187            "data": account_base64,
188            "account_id": account.id().to_string(),
189        });
190
191        let cosigner_commitments = account.cosigner_commitments_hex();
192        let auth_config = AuthConfig {
193            auth_type: Some(match self.key_manager.scheme() {
194                SignatureScheme::Falcon => AuthType::MidenFalconRpo(MidenFalconRpoAuth {
195                    cosigner_commitments,
196                }),
197                SignatureScheme::Ecdsa => AuthType::MidenEcdsa(MidenEcdsaAuth {
198                    cosigner_commitments,
199                }),
200            }),
201        };
202
203        let account_id = account.id();
204
205        // Configure account on GUARDIAN
206        guardian_client
207            .configure(&account_id, auth_config, initial_state)
208            .await
209            .map_err(|e| {
210                MultisigError::GuardianServer(format!("failed to configure account: {}", e))
211            })?;
212
213        Ok(())
214    }
215
216    /// Syncs state with the Miden network.
217    pub async fn sync(&mut self) -> Result<()> {
218        self.sync_network_state().await?;
219
220        let account_updated = self.sync_from_guardian_internal().await?;
221
222        if account_updated {
223            self.sync_network_state().await?;
224        }
225
226        self.refresh_cached_account_from_store().await
227    }
228
229    /// Syncs only with the Miden network and refreshes local cached account state.
230    pub async fn sync_network_only(&mut self) -> Result<()> {
231        self.sync_network_state().await?;
232        self.refresh_cached_account_from_store().await
233    }
234
235    /// Syncs account state from GUARDIAN into the local miden-client store.
236    pub async fn sync_from_guardian(&mut self) -> Result<()> {
237        self.sync_from_guardian_internal().await?;
238        Ok(())
239    }
240
241    async fn sync_network_state(&mut self) -> Result<()> {
242        self.miden_client
243            .sync_state()
244            .await
245            .map_err(|e| MultisigError::MidenClient(format!("failed to sync state: {:#?}", e)))?;
246        Ok(())
247    }
248
249    async fn refresh_cached_account_from_store(&mut self) -> Result<()> {
250        if let Some(current) = self.account.take() {
251            let account_id = current.id();
252            let account_record = self
253                .miden_client
254                .get_account(account_id)
255                .await
256                .map_err(|e| {
257                    MultisigError::MidenClient(format!("failed to get updated account: {}", e))
258                })?
259                .ok_or_else(|| {
260                    MultisigError::MissingConfig("account not found after sync".to_string())
261                })?;
262            let account: Account = account_record;
263            let refreshed = MultisigAccount::new(account);
264            self.account = Some(refreshed);
265        }
266
267        Ok(())
268    }
269
270    /// Explicitly verifies that local account state commitment matches on-chain commitment.
271    pub async fn verify_state_commitment(&self) -> Result<StateVerificationResult> {
272        let account = self.require_account()?;
273        let account_id = account.id();
274        let local_commitment = account.commitment();
275        let on_chain_commitment = self.get_on_chain_account_commitment(account_id).await?;
276
277        if local_commitment != on_chain_commitment {
278            return Err(MultisigError::InvalidConfig(format!(
279                "local account commitment does not match on-chain commitment for account {}: local={}, on_chain={}",
280                account_id,
281                word_to_hex(&local_commitment),
282                word_to_hex(&on_chain_commitment)
283            )));
284        }
285
286        Ok(StateVerificationResult {
287            account_id,
288            local_commitment_hex: word_to_hex(&local_commitment),
289            on_chain_commitment_hex: word_to_hex(&on_chain_commitment),
290        })
291    }
292
293    async fn ensure_safe_to_overwrite_local_state(
294        &self,
295        account_id: AccountId,
296        incoming_commitment: Word,
297    ) -> Result<()> {
298        match self.try_get_on_chain_account_commitment(account_id).await? {
299            None => Ok(()),
300            Some(on_chain_commitment) if on_chain_commitment == incoming_commitment => Ok(()),
301            Some(on_chain_commitment) => Err(MultisigError::InvalidConfig(format!(
302                "refusing to overwrite local state: incoming commitment does not match on-chain commitment for account {}: incoming={}, on_chain={}",
303                account_id,
304                word_to_hex(&incoming_commitment),
305                word_to_hex(&on_chain_commitment)
306            ))),
307        }
308    }
309    /// Internal sync from GUARDIAN that returns whether the account was updated.
310    async fn sync_from_guardian_internal(&mut self) -> Result<bool> {
311        let account = self.require_account()?;
312        let account_id = account.id();
313        let local_commitment = account.inner().to_commitment();
314        let local_nonce = account.nonce();
315
316        // Fetch state from GUARDIAN
317        let mut guardian_client = self.create_authenticated_guardian_client().await?;
318        let state_response = guardian_client.get_state(&account_id).await.map_err(|e| {
319            MultisigError::GuardianServer(format!("failed to get state from GUARDIAN: {}", e))
320        })?;
321
322        let state_obj = state_response.state.ok_or_else(|| {
323            MultisigError::GuardianServer("no state returned from GUARDIAN".to_string())
324        })?;
325
326        // Parse GUARDIAN commitment
327        let guardian_commitment_hex = &state_obj.commitment;
328        let guardian_commitment =
329            word_from_hex(guardian_commitment_hex).map_err(MultisigError::HexDecode)?;
330
331        // Compare commitments - if they match, no update needed
332        if local_commitment == guardian_commitment {
333            return Ok(false);
334        }
335
336        // Commitments differ - deserialize GUARDIAN state to check nonce
337        let state_value: serde_json::Value = serde_json::from_str(&state_obj.state_json)?;
338
339        let account_base64 = state_value["data"].as_str().ok_or_else(|| {
340            MultisigError::GuardianServer("missing 'data' field in state".to_string())
341        })?;
342
343        let account_bytes = base64::engine::general_purpose::STANDARD
344            .decode(account_base64)
345            .map_err(|e| MultisigError::MidenClient(format!("failed to decode account: {}", e)))?;
346
347        let fresh_account = Account::read_from_bytes(&account_bytes).map_err(|e| {
348            MultisigError::MidenClient(format!("failed to deserialize account: {}", e))
349        })?;
350
351        // Compare nonces - if local is newer or equal, don't overwrite with GUARDIAN's older state.
352        // This happens after executing a transaction before GUARDIAN canonicalizes.
353        let guardian_nonce = fresh_account.nonce().as_canonical_u64();
354        if local_nonce >= guardian_nonce {
355            // Local state is newer, skip GUARDIAN update
356            return Ok(false);
357        }
358
359        self.ensure_safe_to_overwrite_local_state(account_id, fresh_account.to_commitment())
360            .await?;
361
362        // GUARDIAN has newer state - try to add/update.
363        // If we get a commitment mismatch (locked state), reset and retry.
364        match self.add_or_update_account(&fresh_account, true).await {
365            Ok(()) => {}
366            Err(e)
367                if e.to_string()
368                    .contains("doesn't match the imported account commitment") =>
369            {
370                // Reset miden-client and try again with fresh state
371                self.reset_miden_client().await?;
372                self.add_or_update_account(&fresh_account, true).await?;
373            }
374            Err(e) => return Err(e),
375        }
376
377        let multisig_account = MultisigAccount::new(fresh_account);
378        self.account = Some(multisig_account);
379
380        Ok(true)
381    }
382
383    /// Fetches deltas from GUARDIAN since the current local nonce and applies them to the local account.
384    pub async fn get_deltas(&mut self) -> Result<()> {
385        let account = self.require_account()?.clone();
386        let account_id = account.id();
387        let current_nonce = account.nonce();
388        let from_nonce = current_nonce.saturating_add(1);
389
390        let mut guardian_client = self.create_authenticated_guardian_client().await?;
391        let response = match guardian_client
392            .get_delta_since(&account_id, from_nonce)
393            .await
394        {
395            Ok(resp) => resp,
396            Err(GuardianClientError::ServerError(msg)) if msg.contains("not found") => {
397                // No new deltas since current nonce - this is not an error
398                return Ok(());
399            }
400            Err(e) => {
401                return Err(MultisigError::GuardianServer(format!(
402                    "failed to pull deltas from GUARDIAN: {}",
403                    e
404                )));
405            }
406        };
407
408        let merged_delta = response.merged_delta.ok_or_else(|| {
409            MultisigError::GuardianServer("no merged_delta in response".to_string())
410        })?;
411
412        let expected_prev_commitment = if merged_delta.prev_commitment.is_empty() {
413            None
414        } else {
415            Some(word_from_hex(&merged_delta.prev_commitment).map_err(MultisigError::HexDecode)?)
416        };
417
418        if let Some(prev_commitment) = expected_prev_commitment
419            && account.commitment() != prev_commitment
420        {
421            return Ok(());
422        }
423
424        let tx_summary = merged_delta.try_into_tx_summary().map_err(|e| {
425            MultisigError::MidenClient(format!("failed to parse delta payload: {}", e))
426        })?;
427
428        let account_delta = tx_summary.account_delta();
429
430        let updated_account: Account = if account_delta.is_full_state() {
431            Account::try_from(account_delta).map_err(|e| {
432                MultisigError::MidenClient(format!(
433                    "failed to convert full state delta to account: {}",
434                    e
435                ))
436            })?
437        } else {
438            let mut acc: Account = account.into_inner();
439            acc.apply_delta(account_delta).map_err(|e| {
440                MultisigError::MidenClient(format!("failed to apply delta to account: {}", e))
441            })?;
442            acc
443        };
444
445        self.ensure_safe_to_overwrite_local_state(account_id, updated_account.to_commitment())
446            .await?;
447
448        // Try to add/update account. If we get a commitment mismatch, reset the miden client
449        // and re-import the account fresh from GUARDIAN to recover from locked/stale state.
450        match self.add_or_update_account(&updated_account, true).await {
451            Ok(()) => {
452                let multisig_account = MultisigAccount::new(updated_account);
453                self.account = Some(multisig_account);
454                Ok(())
455            }
456            Err(e)
457                if e.to_string()
458                    .contains("doesn't match the imported account commitment") =>
459            {
460                // The miden-client store has the account in a stale/locked state.
461                // Reset the client and re-pull fresh state from GUARDIAN.
462                self.reset_miden_client().await?;
463
464                // Re-pull fresh state from GUARDIAN
465                let mut guardian_client = self.create_authenticated_guardian_client().await?;
466                let state_response = guardian_client.get_state(&account_id).await.map_err(|e| {
467                    MultisigError::GuardianServer(format!("failed to get state: {}", e))
468                })?;
469
470                let state_obj = state_response.state.ok_or_else(|| {
471                    MultisigError::GuardianServer("no state returned from GUARDIAN".to_string())
472                })?;
473
474                let state_value: serde_json::Value = serde_json::from_str(&state_obj.state_json)?;
475
476                let account_base64 = state_value["data"].as_str().ok_or_else(|| {
477                    MultisigError::GuardianServer("missing 'data' field in state".to_string())
478                })?;
479
480                let account_bytes = base64::engine::general_purpose::STANDARD
481                    .decode(account_base64)
482                    .map_err(|e| {
483                        MultisigError::MidenClient(format!("failed to decode account: {}", e))
484                    })?;
485
486                let fresh_account = Account::read_from_bytes(&account_bytes).map_err(|e| {
487                    MultisigError::MidenClient(format!("failed to deserialize account: {}", e))
488                })?;
489
490                self.ensure_safe_to_overwrite_local_state(
491                    account_id,
492                    fresh_account.to_commitment(),
493                )
494                .await?;
495
496                self.add_or_update_account(&fresh_account, true).await?;
497
498                let multisig_account = MultisigAccount::new(fresh_account);
499                self.account = Some(multisig_account);
500                Ok(())
501            }
502            Err(e) => Err(e),
503        }
504    }
505
506    /// Registers the current account on the GUARDIAN server.
507    ///
508    /// # Example
509    ///
510    /// ```ignore
511    /// // After switching GUARDIAN endpoints
512    /// client.set_guardian_endpoint("http://new-guardian:50051");
513    /// client.register_on_guardian().await?;
514    /// ```
515    pub async fn register_on_guardian(&mut self) -> Result<()> {
516        self.push_account().await
517    }
518
519    /// Changes the GUARDIAN endpoint and optionally registers the account on the new server.
520    ///
521    /// # Arguments
522    ///
523    /// * `new_endpoint` - The new GUARDIAN server endpoint URL
524    /// * `register` - If true, registers the current account on the new GUARDIAN server
525    ///
526    /// # Example
527    ///
528    /// ```ignore
529    /// // GUARDIAN server moved to new URL (same keys, no on-chain change needed)
530    /// client.set_guardian_endpoint("http://new-guardian:50051", true).await?;
531    /// ```
532    pub async fn set_guardian_endpoint(
533        &mut self,
534        new_endpoint: &str,
535        register: bool,
536    ) -> Result<()> {
537        self.guardian_endpoint = new_endpoint.to_string();
538
539        if register {
540            self.register_on_guardian().await?;
541        }
542
543        Ok(())
544    }
545}
546
547#[cfg(test)]
548mod tests {
549    use super::*;
550
551    fn word(value: u32) -> Word {
552        Word::from([value, 0, 0, 0])
553    }
554
555    #[test]
556    fn ensure_unique_signer_commitments_rejects_duplicates() {
557        let result = MultisigClient::ensure_unique_signer_commitments(&[word(1), word(2), word(1)]);
558        assert!(result.is_err());
559        assert!(
560            result
561                .unwrap_err()
562                .to_string()
563                .contains("duplicate signer commitment")
564        );
565    }
566}