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::{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
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 = 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 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::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 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 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}