Skip to main content

miden_multisig_client/client/
helpers.rs

1//! Internal helper functions for GUARDIAN client interactions.
2
3use crate::guardian_endpoint::verify_endpoint_commitment;
4use guardian_client::GuardianClient;
5#[cfg(test)]
6use guardian_shared::FromJson;
7use guardian_shared::SignatureScheme;
8use guardian_shared::ToJson;
9use miden_client::account::Account;
10use miden_client::rpc::domain::account::GetAccountRequest;
11use miden_client::rpc::{GrpcClient, GrpcError, NodeRpcClient, RpcError};
12use miden_client::transaction::{TransactionRequest, TransactionSummary};
13use miden_protocol::Word;
14use miden_protocol::account::AccountId;
15use miden_protocol::utils::serde::Serializable;
16
17use super::MultisigClient;
18use crate::account::MultisigAccount;
19use crate::builder::create_miden_client;
20use crate::error::{MultisigError, Result, error_chain};
21use crate::execution::build_final_transaction_request;
22use crate::keystore::word_from_hex;
23use crate::proposal::{Proposal, TransactionType};
24use crate::transaction::word_to_hex;
25
26/// True for note-less storage-config transactions, whose post-submit state miden-client persists
27/// incorrectly for private accounts, so local state must be rebuilt from the proven delta instead.
28fn rebuilds_local_state_from_delta(transaction_type: &TransactionType) -> bool {
29    match transaction_type {
30        TransactionType::AddCosigner { .. }
31        | TransactionType::RemoveCosigner { .. }
32        | TransactionType::UpdateSigners { .. }
33        | TransactionType::UpdateProcedureThreshold { .. }
34        | TransactionType::SwitchGuardian { .. } => true,
35        TransactionType::P2ID { .. }
36        | TransactionType::ConsumeNotes { .. }
37        | TransactionType::Custom => false,
38    }
39}
40
41impl MultisigClient {
42    /// Creates a GUARDIAN client (unauthenticated).
43    pub(crate) async fn create_guardian_client(&self) -> Result<GuardianClient> {
44        GuardianClient::connect(&self.guardian_endpoint)
45            .await
46            .map_err(|e| MultisigError::GuardianConnection(e.to_string()))
47    }
48
49    /// Creates an authenticated GUARDIAN client.
50    pub(crate) async fn create_authenticated_guardian_client(&self) -> Result<GuardianClient> {
51        let client = self.create_guardian_client().await?;
52        Ok(client.with_signer(self.key_manager.clone()))
53    }
54
55    pub(crate) async fn get_on_chain_account_commitment(
56        &self,
57        account_id: AccountId,
58    ) -> Result<Word> {
59        let rpc_client = GrpcClient::new(&self.miden_endpoint, 10_000);
60        let (_, proof) = rpc_client
61            .get_account(account_id, GetAccountRequest::new())
62            .await
63            .map_err(|e| match e {
64                RpcError::RequestError {
65                    error_kind: GrpcError::NotFound,
66                    ..
67                } => {
68                    MultisigError::MidenClient(format!("account {} not found on chain", account_id))
69                }
70                other => MultisigError::MidenClient(format!(
71                    "failed to fetch on-chain commitment for account {}: {}",
72                    account_id, other
73                )),
74            })?;
75
76        Ok(proof.account_witness().state_commitment())
77    }
78
79    pub(crate) async fn try_get_on_chain_account_commitment(
80        &self,
81        account_id: AccountId,
82    ) -> Result<Option<Word>> {
83        let rpc_client = GrpcClient::new(&self.miden_endpoint, 10_000);
84        match rpc_client
85            .get_account(account_id, GetAccountRequest::new())
86            .await
87        {
88            Ok((_, proof)) => {
89                let commitment = proof.account_witness().state_commitment();
90                if commitment == Word::default() {
91                    Ok(None)
92                } else {
93                    Ok(Some(commitment))
94                }
95            }
96            Err(RpcError::RequestError {
97                error_kind: GrpcError::NotFound,
98                ..
99            }) => Ok(None),
100            Err(e) => Err(MultisigError::MidenClient(format!(
101                "failed to fetch on-chain commitment for account {}: {}",
102                account_id, e
103            ))),
104        }
105    }
106
107    /// Returns a reference to the current account, or error if none loaded.
108    pub(crate) fn require_account(&self) -> Result<&MultisigAccount> {
109        self.account
110            .as_ref()
111            .ok_or_else(|| MultisigError::MissingConfig("no account loaded".to_string()))
112    }
113
114    pub(crate) fn ensure_proposal_account_id(
115        proposal_account_id: &str,
116        expected_account_id: &AccountId,
117    ) -> Result<()> {
118        if proposal_account_id.eq_ignore_ascii_case(&expected_account_id.to_string()) {
119            return Ok(());
120        }
121
122        Err(MultisigError::InvalidConfig(format!(
123            "proposal is for account {} instead of {}",
124            proposal_account_id, expected_account_id
125        )))
126    }
127
128    /// Gets the GUARDIAN acknowledgment signature for a transaction.
129    ///
130    /// This pushes the delta to GUARDIAN and retrieves the server's signature.
131    pub(crate) async fn get_guardian_ack_signature(
132        &mut self,
133        account: &MultisigAccount,
134        nonce: u64,
135        tx_summary: &TransactionSummary,
136        tx_summary_commitment: Word,
137    ) -> Result<crate::execution::SignatureAdvice> {
138        let account_id = account.id();
139        let prev_commitment = format!(
140            "0x{}",
141            hex::encode(Serializable::to_bytes(&account.commitment()))
142        );
143
144        // Push delta to GUARDIAN to get acknowledgment signature
145        let mut guardian_client = self.create_authenticated_guardian_client().await?;
146        let delta_payload = tx_summary.to_json();
147
148        let push_response = guardian_client
149            .push_delta(&account_id, nonce, &prev_commitment, &delta_payload)
150            .await
151            .map_err(|e| MultisigError::GuardianServer(format!("failed to push delta: {}", e)))?;
152
153        // Get GUARDIAN ack signature
154        let ack_sig = push_response.ack_sig.ok_or_else(|| {
155            MultisigError::GuardianServer(
156                "GUARDIAN did not return acknowledgment signature".to_string(),
157            )
158        })?;
159        let ack_scheme = push_response
160            .delta
161            .as_ref()
162            .and_then(|delta| delta.ack_scheme.as_deref())
163            .ok_or_else(|| {
164                MultisigError::GuardianServer(
165                    "GUARDIAN did not return acknowledgment scheme".to_string(),
166                )
167            })
168            .and_then(|ack_scheme| {
169                SignatureScheme::from(ack_scheme).map_err(MultisigError::GuardianServer)
170            })?;
171
172        let (guardian_commitment_hex, raw_pubkey) = guardian_client
173            .get_pubkey(Some(ack_scheme.as_str()))
174            .await
175            .map_err(|e| {
176                MultisigError::GuardianServer(format!("failed to get GUARDIAN commitment: {}", e))
177            })?;
178
179        let guardian_commitment =
180            word_from_hex(&guardian_commitment_hex).map_err(MultisigError::HexDecode)?;
181        let expected_guardian_commitment = account.guardian_commitment()?;
182        if guardian_commitment != expected_guardian_commitment {
183            return Err(MultisigError::GuardianServer(format!(
184                "GUARDIAN public key commitment {} does not match account commitment {}",
185                word_to_hex(&guardian_commitment),
186                word_to_hex(&expected_guardian_commitment)
187            )));
188        }
189
190        let ack_signature = ack_scheme
191            .parse_signature_hex(&ack_sig)
192            .map_err(MultisigError::Signature)?;
193        ack_scheme
194            .build_signature_advice_entry(
195                guardian_commitment,
196                tx_summary_commitment,
197                &ack_signature,
198                push_response
199                    .delta
200                    .as_ref()
201                    .and_then(|delta| delta.ack_pubkey.as_deref())
202                    .or(raw_pubkey.as_deref()),
203            )
204            .map_err(MultisigError::Signature)
205    }
206
207    /// Verifies that a proposals metadata reconstructs the same tx_summary commitment.
208    pub(crate) async fn verify_proposal_summary_binding(
209        &mut self,
210        proposal: &Proposal,
211    ) -> Result<()> {
212        let tx_summary_commitment = proposal.tx_summary.to_commitment();
213
214        let proposal_id_commitment = word_to_hex(&tx_summary_commitment);
215        if !proposal.id.eq_ignore_ascii_case(&proposal_id_commitment) {
216            return Err(MultisigError::InvalidConfig(format!(
217                "proposal id {} does not match tx_summary commitment {}",
218                proposal.id, proposal_id_commitment
219            )));
220        }
221
222        // Custom proposal types (issue #266) have no per-type reconstruction
223        // recipe; the id ↔ tx_summary commitment match above is the only
224        // available integrity guarantee for an opaque proposal. Guard the one
225        // piece of transport metadata that gates readiness: a malformed payload
226        // must not declare fewer required signatures than the account threshold,
227        // or it could mark a custom proposal ready with too few cosigners.
228        if matches!(proposal.transaction_type, TransactionType::Custom) {
229            let account_threshold = self.require_account()?.threshold()? as usize;
230            let declared = proposal
231                .metadata
232                .required_signatures
233                .unwrap_or(account_threshold);
234            if declared < account_threshold {
235                return Err(MultisigError::InvalidConfig(format!(
236                    "custom proposal {} declares {} required signatures, below the account threshold {}",
237                    proposal.id, declared, account_threshold
238                )));
239            }
240            return Ok(());
241        }
242
243        let account = self.require_account()?.clone();
244        let salt = proposal.metadata.salt()?;
245        let signer_commitments = proposal.metadata.signer_commitments()?;
246
247        let tx_request = build_final_transaction_request(
248            &self.miden_client,
249            &proposal.transaction_type,
250            account.inner(),
251            salt,
252            Vec::new(),
253            proposal.metadata.new_threshold,
254            Some(signer_commitments.as_slice()),
255            self.key_manager.scheme(),
256        )
257        .await?;
258
259        let reconstructed = crate::transaction::execute_for_summary(
260            &mut self.miden_client,
261            account.id(),
262            tx_request,
263        )
264        .await?;
265
266        if reconstructed.to_commitment() != tx_summary_commitment {
267            return Err(MultisigError::InvalidConfig(format!(
268                "proposal {} metadata does not match tx_summary",
269                proposal.id
270            )));
271        }
272
273        Ok(())
274    }
275
276    #[cfg(test)]
277    pub(crate) fn proposal_id_from_delta_payload(delta_payload: &str) -> Result<String> {
278        let payload_json: serde_json::Value = serde_json::from_str(delta_payload).map_err(|e| {
279            MultisigError::InvalidConfig(format!("failed to parse proposal delta payload: {}", e))
280        })?;
281        let tx_summary_json = payload_json.get("tx_summary").ok_or_else(|| {
282            MultisigError::InvalidConfig("missing tx_summary in delta payload".to_string())
283        })?;
284        let tx_summary = TransactionSummary::from_json(tx_summary_json).map_err(|e| {
285            MultisigError::InvalidConfig(format!("failed to parse tx_summary: {}", e))
286        })?;
287        Ok(word_to_hex(&tx_summary.to_commitment()))
288    }
289
290    /// Finalizes a transaction by executing it on-chain and updating local state.
291    ///
292    /// This handles the common post-execution logic for all proposal types.
293    ///
294    /// Note-less storage-config changes on private accounts take a manual
295    /// execute/prove/submit pipeline and rebuild the account from the proven
296    /// delta: the standard submit path otherwise leaves stale local state
297    /// (cleared storage-map entries linger) and stages SMT roots that block a
298    /// corrective overwrite. Note-bearing transactions keep the standard path to
299    /// preserve note tracking. A full-state delta (private accounts) converts
300    /// directly into the account; an incremental delta is applied onto the base.
301    ///
302    /// For a `SwitchGuardian` the new endpoint is registered only when it
303    /// actually differs from the current one. On an unchanged endpoint the
304    /// pushed switch delta canonicalizes normally; re-registering would overwrite
305    /// the pre-switch base and double-apply the delta. Post-submit `sync_state`
306    /// failures are ignored because GUARDIAN may not have canonicalized yet.
307    pub(crate) async fn finalize_transaction(
308        &mut self,
309        account_id: AccountId,
310        tx_request: TransactionRequest,
311        transaction_type: &TransactionType,
312    ) -> Result<()> {
313        if let TransactionType::SwitchGuardian {
314            new_endpoint,
315            new_commitment,
316        } = transaction_type
317        {
318            verify_endpoint_commitment(new_endpoint, *new_commitment).await?;
319        }
320
321        let new_guardian_endpoint =
322            if let TransactionType::SwitchGuardian { new_endpoint, .. } = transaction_type {
323                Some(new_endpoint.clone())
324            } else {
325                None
326            };
327
328        let updated_account: Account = if rebuilds_local_state_from_delta(transaction_type) {
329            let base_account: Account = self
330                .miden_client
331                .get_account(account_id)
332                .await
333                .map_err(|e| {
334                    MultisigError::MidenClient(format!(
335                        "failed to get account before execution: {}",
336                        e
337                    ))
338                })?
339                .ok_or_else(|| {
340                    MultisigError::MissingConfig("account not found before execution".to_string())
341                })?;
342
343            let tx_result = self
344                .miden_client
345                .execute_transaction(account_id, tx_request)
346                .await
347                .map_err(|e| {
348                    MultisigError::TransactionExecution(format!(
349                        "transaction execution failed: {:?}",
350                        e
351                    ))
352                })?;
353
354            let proven = self
355                .miden_client
356                .prove_transaction(&tx_result)
357                .await
358                .map_err(|e| {
359                    MultisigError::TransactionExecution(format!(
360                        "transaction proving failed: {:?}",
361                        e
362                    ))
363                })?;
364
365            self.miden_client
366                .submit_proven_transaction(proven, &tx_result)
367                .await
368                .map_err(|e| {
369                    MultisigError::TransactionExecution(format!(
370                        "transaction submission failed: {:?}",
371                        e
372                    ))
373                })?;
374
375            let account_delta = tx_result.account_delta();
376            let rebuilt: Account = if account_delta.is_full_state() {
377                Account::try_from(account_delta).map_err(|e| {
378                    MultisigError::MidenClient(format!(
379                        "failed to build account from full state delta: {}",
380                        e
381                    ))
382                })?
383            } else {
384                let mut acc = base_account;
385                acc.apply_delta(account_delta).map_err(|e| {
386                    MultisigError::MidenClient(format!(
387                        "failed to apply transaction delta to account: {}",
388                        e
389                    ))
390                })?;
391                acc
392            };
393
394            self.add_or_update_account(&rebuilt, true).await?;
395
396            let _ = self.miden_client.sync_state().await;
397
398            rebuilt
399        } else {
400            self.miden_client
401                .submit_new_transaction(account_id, tx_request)
402                .await
403                .map_err(|e| {
404                    MultisigError::TransactionExecution(format!(
405                        "transaction execution failed: {:?}",
406                        e
407                    ))
408                })?;
409
410            let _ = self.miden_client.sync_state().await;
411
412            self.miden_client
413                .get_account(account_id)
414                .await
415                .map_err(|e| {
416                    MultisigError::MidenClient(format!("failed to get updated account: {}", e))
417                })?
418                .ok_or_else(|| {
419                    MultisigError::MissingConfig("account not found after execution".to_string())
420                })?
421        };
422
423        if let Some(endpoint) = new_guardian_endpoint {
424            let switching_endpoint = endpoint != self.guardian_endpoint;
425            self.guardian_endpoint = endpoint;
426            self.account = Some(MultisigAccount::new(updated_account.clone()));
427
428            if switching_endpoint {
429                self.register_on_guardian().await.map_err(|e| {
430                    MultisigError::GuardianServer(format!(
431                        "transaction executed successfully but failed to register on new GUARDIAN: {}",
432                        e
433                    ))
434                })?;
435            }
436        } else {
437            let multisig_account = MultisigAccount::new(updated_account);
438            self.account = Some(multisig_account);
439        }
440
441        Ok(())
442    }
443
444    /// Resets the miden-client by creating a new instance with a fresh database.
445    pub async fn reset_miden_client(&mut self) -> Result<()> {
446        self.miden_client = create_miden_client(&self.account_dir, &self.miden_endpoint).await?;
447        Ok(())
448    }
449
450    /// Adds an account to miden-client if it doesn't exist, or updates it if it does.
451    pub(crate) async fn add_or_update_account(
452        &mut self,
453        account: &Account,
454        imported: bool,
455    ) -> Result<()> {
456        let account_id = account.id();
457
458        let existing = self
459            .miden_client
460            .get_account(account_id)
461            .await
462            .map_err(|e| {
463                MultisigError::MidenClient(format!("failed to check account: {}", error_chain(&e)))
464            })?;
465
466        if existing.is_some() {
467            self.miden_client
468                .add_account(account, true)
469                .await
470                .map_err(|e| {
471                    MultisigError::MidenClient(format!(
472                        "failed to update account: {}",
473                        error_chain(&e)
474                    ))
475                })?;
476        } else {
477            self.miden_client
478                .add_account(account, imported)
479                .await
480                .map_err(|e| {
481                    MultisigError::MidenClient(format!(
482                        "failed to add account: {}",
483                        error_chain(&e)
484                    ))
485                })?;
486        }
487
488        Ok(())
489    }
490}
491
492#[cfg(test)]
493mod tests {
494    use guardian_shared::FromJson;
495    use guardian_shared::ToJson;
496    use miden_protocol::account::AccountId;
497    use miden_protocol::account::delta::{AccountDelta, AccountStorageDelta, AccountVaultDelta};
498    use miden_protocol::transaction::{InputNotes, RawOutputNotes, TransactionSummary};
499    use miden_protocol::{Felt, Word};
500
501    use super::MultisigClient;
502
503    fn tx_summary_json() -> serde_json::Value {
504        let account_id = AccountId::from_hex("0x7b7b7b7a7b7b7b017b7b7b7b7b7b7b").unwrap();
505        let delta = AccountDelta::new(
506            account_id,
507            AccountStorageDelta::default(),
508            AccountVaultDelta::default(),
509            Felt::ZERO,
510        )
511        .unwrap();
512        TransactionSummary::new(
513            delta,
514            InputNotes::new(Vec::new()).unwrap(),
515            RawOutputNotes::new(Vec::new()).unwrap(),
516            Word::default(),
517        )
518        .to_json()
519    }
520
521    #[test]
522    fn proposal_id_from_delta_payload_returns_tx_summary_commitment() {
523        let tx_summary = TransactionSummary::from_json(&tx_summary_json()).unwrap();
524        let expected_id = crate::transaction::word_to_hex(&tx_summary.to_commitment());
525        let delta_payload = serde_json::json!({
526            "tx_summary": tx_summary_json(),
527            "metadata": {
528                "proposal_type": "change_threshold",
529                "target_threshold": 1,
530                "signer_commitments": []
531            }
532        })
533        .to_string();
534
535        let proposal_id = MultisigClient::proposal_id_from_delta_payload(&delta_payload).unwrap();
536
537        assert_eq!(proposal_id, expected_id);
538    }
539
540    #[test]
541    fn proposal_id_from_delta_payload_rejects_missing_tx_summary() {
542        let result = MultisigClient::proposal_id_from_delta_payload("{\"metadata\":{}}");
543
544        assert!(result.is_err());
545    }
546
547    #[test]
548    fn ensure_proposal_account_id_accepts_matching_account() {
549        let account_id = AccountId::from_hex("0x7b7b7b7a7b7b7b017b7b7b7b7b7b7b").unwrap();
550
551        let result = MultisigClient::ensure_proposal_account_id(
552            "0x7b7b7b7a7b7b7b017b7b7b7b7b7b7b",
553            &account_id,
554        );
555
556        assert!(result.is_ok());
557    }
558
559    #[test]
560    fn ensure_proposal_account_id_rejects_mismatched_account() {
561        let account_id = AccountId::from_hex("0x7b7b7b7a7b7b7b017b7b7b7b7b7b7b").unwrap();
562
563        let error = MultisigClient::ensure_proposal_account_id(
564            "0x8a8a8a8a8a8a8a010a8a8a8a8a8a8a",
565            &account_id,
566        )
567        .unwrap_err();
568
569        assert_eq!(
570            error.to_string(),
571            "invalid configuration: proposal is for account 0x8a8a8a8a8a8a8a010a8a8a8a8a8a8a instead of 0x7b7b7b7a7b7b7b017b7b7b7b7b7b7b"
572        );
573    }
574}