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::{GrpcError, 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 = self.node_rpc_client();
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::miden_rpc_with_context(
71                    format!("failed to fetch on-chain commitment for account {account_id}"),
72                    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 = self.node_rpc_client();
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::miden_rpc_with_context(
101                format!("failed to fetch on-chain commitment for account {account_id}"),
102                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::miden_client_with_context(
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::transaction_execution_with_context(
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::transaction_execution_with_context(
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::transaction_execution_with_context(
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::transaction_execution_with_context(
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(
447            &self.account_dir,
448            &self.miden_endpoint,
449            self.note_transport_endpoint.as_deref(),
450            &self.prover_config,
451            &self.rpc_config,
452        )
453        .await?;
454        Ok(())
455    }
456
457    /// Adds an account to miden-client if it doesn't exist, or updates it if it does.
458    pub(crate) async fn add_or_update_account(
459        &mut self,
460        account: &Account,
461        imported: bool,
462    ) -> Result<()> {
463        let account_id = account.id();
464
465        let existing = self
466            .miden_client
467            .get_account(account_id)
468            .await
469            .map_err(|e| {
470                MultisigError::MidenClient(format!("failed to check account: {}", error_chain(&e)))
471            })?;
472
473        if existing.is_some() {
474            self.miden_client
475                .add_account(account, true)
476                .await
477                .map_err(|e| {
478                    MultisigError::MidenClient(format!(
479                        "failed to update account: {}",
480                        error_chain(&e)
481                    ))
482                })?;
483        } else {
484            self.miden_client
485                .add_account(account, imported)
486                .await
487                .map_err(|e| {
488                    MultisigError::MidenClient(format!(
489                        "failed to add account: {}",
490                        error_chain(&e)
491                    ))
492                })?;
493        }
494
495        Ok(())
496    }
497}
498
499#[cfg(test)]
500mod tests {
501    use guardian_shared::FromJson;
502    use guardian_shared::ToJson;
503    use miden_protocol::account::AccountId;
504    use miden_protocol::account::delta::{AccountDelta, AccountStorageDelta, AccountVaultDelta};
505    use miden_protocol::transaction::{InputNotes, RawOutputNotes, TransactionSummary};
506    use miden_protocol::{Felt, Word};
507
508    use super::MultisigClient;
509
510    fn tx_summary_json() -> serde_json::Value {
511        let account_id = AccountId::from_hex("0x7b7b7b7a7b7b7b017b7b7b7b7b7b7b").unwrap();
512        let delta = AccountDelta::new(
513            account_id,
514            AccountStorageDelta::default(),
515            AccountVaultDelta::default(),
516            Felt::ZERO,
517        )
518        .unwrap();
519        TransactionSummary::new(
520            delta,
521            InputNotes::new(Vec::new()).unwrap(),
522            RawOutputNotes::new(Vec::new()).unwrap(),
523            Word::default(),
524        )
525        .to_json()
526    }
527
528    #[test]
529    fn proposal_id_from_delta_payload_returns_tx_summary_commitment() {
530        let tx_summary = TransactionSummary::from_json(&tx_summary_json()).unwrap();
531        let expected_id = crate::transaction::word_to_hex(&tx_summary.to_commitment());
532        let delta_payload = serde_json::json!({
533            "tx_summary": tx_summary_json(),
534            "metadata": {
535                "proposal_type": "change_threshold",
536                "target_threshold": 1,
537                "signer_commitments": []
538            }
539        })
540        .to_string();
541
542        let proposal_id = MultisigClient::proposal_id_from_delta_payload(&delta_payload).unwrap();
543
544        assert_eq!(proposal_id, expected_id);
545    }
546
547    #[test]
548    fn proposal_id_from_delta_payload_rejects_missing_tx_summary() {
549        let result = MultisigClient::proposal_id_from_delta_payload("{\"metadata\":{}}");
550
551        assert!(result.is_err());
552    }
553
554    #[test]
555    fn ensure_proposal_account_id_accepts_matching_account() {
556        let account_id = AccountId::from_hex("0x7b7b7b7a7b7b7b017b7b7b7b7b7b7b").unwrap();
557
558        let result = MultisigClient::ensure_proposal_account_id(
559            "0x7b7b7b7a7b7b7b017b7b7b7b7b7b7b",
560            &account_id,
561        );
562
563        assert!(result.is_ok());
564    }
565
566    #[test]
567    fn ensure_proposal_account_id_rejects_mismatched_account() {
568        let account_id = AccountId::from_hex("0x7b7b7b7a7b7b7b017b7b7b7b7b7b7b").unwrap();
569
570        let error = MultisigClient::ensure_proposal_account_id(
571            "0x8a8a8a8a8a8a8a010a8a8a8a8a8a8a",
572            &account_id,
573        )
574        .unwrap_err();
575
576        assert_eq!(
577            error.to_string(),
578            "invalid configuration: proposal is for account 0x8a8a8a8a8a8a8a010a8a8a8a8a8a8a instead of 0x7b7b7b7a7b7b7b017b7b7b7b7b7b7b"
579        );
580    }
581}