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