miden_multisig_client/client/
helpers.rs1use 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
26fn 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 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 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 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 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 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 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 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 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 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 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 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}