Skip to main content

miden_multisig_client/
account.rs

1//! Multisig account wrapper with storage inspection helpers.
2
3use miden_client::Serializable;
4use miden_protocol::Word;
5use miden_protocol::account::{
6    Account, AccountId, AccountStorage, StorageMap, StorageMapKey, StorageSlot, StorageSlotName,
7};
8
9use crate::error::{MultisigError, Result};
10use crate::procedures::ProcedureName;
11use crate::proposal::TransactionType;
12
13// Storage slot names for OpenZeppelin multisig/guardian components
14const OZ_MULTISIG_THRESHOLD_CONFIG: &str = "openzeppelin::multisig::threshold_config";
15const OZ_MULTISIG_SIGNER_PUBKEYS: &str = "openzeppelin::multisig::signer_public_keys";
16const OZ_MULTISIG_PROCEDURE_THRESHOLDS: &str = "openzeppelin::multisig::procedure_thresholds";
17const OZ_GUARDIAN_SELECTOR: &str = "openzeppelin::guardian::selector";
18const OZ_GUARDIAN_PUBLIC_KEY: &str = "openzeppelin::guardian::public_key";
19
20/// Wrapper around a Miden Account with multisig-specific helpers.
21///
22/// This provides convenient access to multisig configuration stored in account storage:
23/// - Threshold config slot: `[threshold, num_signers, 0, 0]`
24/// - Signer commitments map slot: `[index, 0, 0, 0] => COMMITMENT`
25/// - Executed transactions map slot (replay protection)
26/// - Procedure threshold overrides map slot: `PROC_ROOT => [threshold, 0, 0, 0]`
27/// - GUARDIAN selector slot: `[1, 0, 0, 0]` (ON) or `[0, 0, 0, 0]` (OFF)
28/// - GUARDIAN public key map slot
29#[derive(Debug, Clone)]
30pub struct MultisigAccount {
31    account: Account,
32}
33
34impl MultisigAccount {
35    /// Creates a new MultisigAccount wrapper.
36    pub fn new(account: Account) -> Self {
37        Self { account }
38    }
39
40    /// Returns the account ID.
41    pub fn id(&self) -> AccountId {
42        self.account.id()
43    }
44
45    /// Returns the account nonce.
46    pub fn nonce(&self) -> u64 {
47        self.account.nonce().as_canonical_u64()
48    }
49
50    /// Returns the account commitment (hash).
51    pub fn commitment(&self) -> Word {
52        self.account.to_commitment()
53    }
54
55    /// Returns a reference to the underlying Account.
56    pub fn inner(&self) -> &Account {
57        &self.account
58    }
59
60    /// Consumes self and returns the underlying Account.
61    pub fn into_inner(self) -> Account {
62        self.account
63    }
64
65    fn get_item_by_name(&self, slot_name: &str) -> Option<Word> {
66        let slot_name = StorageSlotName::new(slot_name).ok()?;
67        self.account.storage().get_item(&slot_name).ok()
68    }
69
70    fn get_map_item_by_name(&self, slot_name: &str, key: Word) -> Option<Word> {
71        let slot_name = StorageSlotName::new(slot_name).ok()?;
72        self.account.storage().get_map_item(&slot_name, key).ok()
73    }
74
75    /// Returns the multisig threshold from storage.
76    pub fn threshold(&self) -> Result<u32> {
77        let slot_value = self
78            .get_item_by_name(OZ_MULTISIG_THRESHOLD_CONFIG)
79            .ok_or_else(|| {
80                MultisigError::AccountStorage("threshold config slot not found".to_string())
81            })?;
82
83        Ok(slot_value[0].as_canonical_u64() as u32)
84    }
85
86    /// Returns the number of signers from storage.
87    pub fn num_signers(&self) -> Result<u32> {
88        let slot_value = self
89            .get_item_by_name(OZ_MULTISIG_THRESHOLD_CONFIG)
90            .ok_or_else(|| {
91                MultisigError::AccountStorage("threshold config slot not found".to_string())
92            })?;
93
94        Ok(slot_value[1].as_canonical_u64() as u32)
95    }
96
97    /// Returns the configured threshold override for a specific procedure, if present.
98    pub fn procedure_threshold(&self, procedure: ProcedureName) -> Result<Option<u32>> {
99        let value = self.get_map_item_by_name(OZ_MULTISIG_PROCEDURE_THRESHOLDS, procedure.root());
100        let Some(value) = value else {
101            return Ok(None);
102        };
103
104        if value == Word::default() {
105            return Ok(None);
106        }
107
108        let threshold = value[0].as_canonical_u64() as u32;
109        if threshold == 0 {
110            return Ok(None);
111        }
112
113        Ok(Some(threshold))
114    }
115
116    /// Returns all configured per-procedure threshold overrides.
117    pub fn procedure_threshold_overrides(&self) -> Result<Vec<(ProcedureName, u32)>> {
118        let mut overrides = Vec::new();
119        for procedure in ProcedureName::all() {
120            if let Some(threshold) = self.procedure_threshold(*procedure)? {
121                overrides.push((*procedure, threshold));
122            }
123        }
124        Ok(overrides)
125    }
126
127    /// Returns the effective threshold for a procedure (override if present, else default).
128    pub fn effective_threshold_for_procedure(&self, procedure: ProcedureName) -> Result<u32> {
129        Ok(self
130            .procedure_threshold(procedure)?
131            .unwrap_or(self.threshold()?))
132    }
133
134    /// Returns the effective threshold for a transaction type.
135    pub fn effective_threshold_for_transaction(&self, tx_type: &TransactionType) -> Result<u32> {
136        let procedure = match tx_type {
137            TransactionType::P2ID { .. } => ProcedureName::SendAsset,
138            TransactionType::ConsumeNotes { .. } => ProcedureName::ReceiveAsset,
139            TransactionType::AddCosigner { .. }
140            | TransactionType::RemoveCosigner { .. }
141            | TransactionType::UpdateSigners { .. } => ProcedureName::UpdateSigners,
142            TransactionType::UpdateProcedureThreshold { .. } => {
143                ProcedureName::UpdateProcedureThreshold
144            }
145            TransactionType::SwitchGuardian { .. } => ProcedureName::UpdateGuardian,
146            TransactionType::Custom => return self.threshold(),
147        };
148
149        self.effective_threshold_for_procedure(procedure)
150    }
151
152    /// Extracts cosigner commitments from signer public keys map slot.
153    ///
154    /// Returns a vector of commitment Words. Returns empty vector if
155    /// the slot is empty or has no entries.
156    pub fn cosigner_commitments(&self) -> Vec<Word> {
157        self.extract_indexed_map_words(OZ_MULTISIG_SIGNER_PUBKEYS)
158    }
159
160    fn extract_indexed_map_words(&self, slot_name: &str) -> Vec<Word> {
161        let mut commitments = Vec::new();
162        let Ok(slot_name) = StorageSlotName::new(slot_name) else {
163            return commitments;
164        };
165
166        let mut index = 0u32;
167        loop {
168            let key = Word::from([index, 0, 0, 0]);
169            match self.account.storage().get_map_item(&slot_name, key) {
170                Ok(value) if value != Word::default() => {
171                    commitments.push(value);
172                    index += 1;
173                }
174                _ => break,
175            }
176        }
177
178        commitments
179    }
180
181    /// Extracts cosigner commitments as hex strings with 0x prefix.
182    pub fn cosigner_commitments_hex(&self) -> Vec<String> {
183        self.cosigner_commitments()
184            .into_iter()
185            .map(|word| format!("0x{}", hex::encode(word.to_bytes())))
186            .collect()
187    }
188
189    /// Checks if the given commitment is a cosigner of this account.
190    pub fn is_cosigner(&self, commitment: &Word) -> bool {
191        self.cosigner_commitments().contains(commitment)
192    }
193
194    /// Returns whether GUARDIAN verification is enabled.
195    pub fn guardian_enabled(&self) -> Result<bool> {
196        let slot_value = self.get_item_by_name(OZ_GUARDIAN_SELECTOR).ok_or_else(|| {
197            MultisigError::AccountStorage("GUARDIAN selector slot not found".to_string())
198        })?;
199
200        Ok(slot_value[0].as_canonical_u64() == 1)
201    }
202
203    /// Returns the GUARDIAN server commitment from GUARDIAN public key map slot.
204    pub fn guardian_commitment(&self) -> Result<Word> {
205        let key = Word::from([0u32, 0, 0, 0]);
206        self.get_map_item_by_name(OZ_GUARDIAN_PUBLIC_KEY, key)
207            .ok_or_else(|| {
208                MultisigError::AccountStorage("GUARDIAN public key slot not found".to_string())
209            })
210    }
211
212    pub fn with_procedure_threshold(
213        &self,
214        procedure: ProcedureName,
215        threshold: u32,
216    ) -> Result<Self> {
217        let mut overrides = self.procedure_threshold_overrides()?;
218        overrides.retain(|(current, _)| *current != procedure);
219        if threshold > 0 {
220            overrides.push((procedure, threshold));
221        }
222
223        let slot_name = StorageSlotName::new(OZ_MULTISIG_PROCEDURE_THRESHOLDS).map_err(|e| {
224            MultisigError::AccountStorage(format!("invalid procedure threshold slot name: {}", e))
225        })?;
226        let entries = overrides.into_iter().map(|(procedure, threshold)| {
227            (
228                StorageMapKey::new(procedure.root()),
229                Word::from([threshold, 0, 0, 0]),
230            )
231        });
232        let map = StorageMap::with_entries(entries).map_err(|e| {
233            MultisigError::AccountStorage(format!("failed to build procedure threshold map: {}", e))
234        })?;
235        let slot = StorageSlot::with_map(slot_name, map);
236
237        let (id, vault, storage, code, nonce, seed) = self.account.clone().into_parts();
238        let storage_slots = storage
239            .into_slots()
240            .into_iter()
241            .filter(|current| current.name().as_str() != OZ_MULTISIG_PROCEDURE_THRESHOLDS)
242            .chain([slot])
243            .collect();
244        let storage = AccountStorage::new(storage_slots).map_err(|e| {
245            MultisigError::AccountStorage(format!("failed to rebuild account storage: {}", e))
246        })?;
247        let account = Account::new_unchecked(id, vault, storage, code, nonce, seed);
248
249        Ok(Self::new(account))
250    }
251}
252
253#[cfg(test)]
254mod tests {
255    use miden_confidential_contracts::multisig_guardian::{
256        MultisigGuardianBuilder, MultisigGuardianConfig,
257    };
258    use miden_protocol::account::{AccountStorage, StorageMap, StorageSlot, StorageSlotName};
259    use miden_protocol::note::NoteId;
260
261    use super::*;
262
263    fn word(v: u32) -> Word {
264        Word::from([v, 0, 0, 0])
265    }
266
267    fn build_test_account() -> MultisigAccount {
268        let config = MultisigGuardianConfig::new(2, vec![word(1), word(2), word(3)], word(99))
269            .with_proc_threshold_overrides(vec![
270                (ProcedureName::SendAsset.root(), 1),
271                (ProcedureName::UpdateSigners.root(), 3),
272                (ProcedureName::UpdateGuardian.root(), 1),
273            ]);
274
275        let account = MultisigGuardianBuilder::new(config)
276            .with_seed([7u8; 32])
277            .build()
278            .expect("account builds");
279
280        MultisigAccount::new(account)
281    }
282
283    fn build_account_with_signer_slots(oz_commitments: Vec<Word>) -> MultisigAccount {
284        fn signer_slot(slot_name: &str, commitments: Vec<Word>) -> StorageSlot {
285            let slot_name = StorageSlotName::new(slot_name).expect("valid slot name");
286            let entries = commitments
287                .into_iter()
288                .enumerate()
289                .map(|(index, commitment)| (StorageMapKey::from_index(index as u32), commitment));
290            let map = StorageMap::with_entries(entries).expect("valid signer map");
291            StorageSlot::with_map(slot_name, map)
292        }
293
294        let account =
295            MultisigGuardianBuilder::new(MultisigGuardianConfig::new(1, vec![word(1)], word(99)))
296                .with_seed([9u8; 32])
297                .build_existing()
298                .expect("account builds");
299        let (id, vault, storage, code, nonce, seed) = account.into_parts();
300        let storage_slots = storage
301            .into_slots()
302            .into_iter()
303            .filter(|slot| slot.name().as_str() != OZ_MULTISIG_SIGNER_PUBKEYS)
304            .chain([signer_slot(OZ_MULTISIG_SIGNER_PUBKEYS, oz_commitments)])
305            .collect();
306        let storage = AccountStorage::new(storage_slots).expect("valid storage");
307        let account = Account::new_unchecked(id, vault, storage, code, nonce, seed);
308
309        MultisigAccount::new(account)
310    }
311
312    #[test]
313    fn effective_threshold_for_procedure_uses_override_or_default() {
314        let account = build_test_account();
315
316        assert_eq!(
317            account
318                .effective_threshold_for_procedure(ProcedureName::SendAsset)
319                .expect("threshold"),
320            1
321        );
322        assert_eq!(
323            account
324                .effective_threshold_for_procedure(ProcedureName::ReceiveAsset)
325                .expect("threshold"),
326            2
327        );
328    }
329
330    #[test]
331    fn effective_threshold_for_transaction_maps_to_expected_procedures() {
332        let account = build_test_account();
333        let account_id =
334            AccountId::from_hex("0x7b7b7b7a7b7b7b017b7b7b7b7b7b7b").expect("account id");
335
336        assert_eq!(
337            account
338                .effective_threshold_for_transaction(&TransactionType::P2ID {
339                    recipient: account_id,
340                    faucet_id: account_id,
341                    amount: 10,
342                })
343                .expect("threshold"),
344            1
345        );
346        assert_eq!(
347            account
348                .effective_threshold_for_transaction(&TransactionType::ConsumeNotes {
349                    note_ids: vec![NoteId::from_raw(word(5))],
350                    metadata_version: None,
351                    notes: Vec::new(),
352                })
353                .expect("threshold"),
354            2
355        );
356        assert_eq!(
357            account
358                .effective_threshold_for_transaction(&TransactionType::AddCosigner {
359                    new_commitment: word(10),
360                })
361                .expect("threshold"),
362            3
363        );
364        assert_eq!(
365            account
366                .effective_threshold_for_transaction(&TransactionType::RemoveCosigner {
367                    commitment: word(2),
368                })
369                .expect("threshold"),
370            3
371        );
372        assert_eq!(
373            account
374                .effective_threshold_for_transaction(&TransactionType::UpdateSigners {
375                    new_threshold: 2,
376                    signer_commitments: vec![word(1), word(2), word(3)],
377                })
378                .expect("threshold"),
379            3
380        );
381        assert_eq!(
382            account
383                .effective_threshold_for_transaction(&TransactionType::SwitchGuardian {
384                    new_endpoint: "http://new-guardian.example.com".to_string(),
385                    new_commitment: word(11),
386                })
387                .expect("threshold"),
388            1
389        );
390        assert_eq!(
391            account
392                .effective_threshold_for_transaction(&TransactionType::Custom)
393                .expect("threshold"),
394            2,
395            "custom proposals use the account default threshold"
396        );
397    }
398
399    #[test]
400    fn cosigner_commitments_reads_openzeppelin_signer_map() {
401        let account = build_account_with_signer_slots(vec![word(11), word(12)]);
402
403        assert_eq!(account.cosigner_commitments(), vec![word(11), word(12)]);
404    }
405
406    #[test]
407    fn cosigner_commitments_returns_empty_when_openzeppelin_signer_map_is_empty() {
408        let account = build_account_with_signer_slots(Vec::new());
409
410        assert!(account.cosigner_commitments().is_empty());
411    }
412
413    #[test]
414    fn with_procedure_threshold_updates_existing_override() {
415        let account = build_test_account();
416
417        let updated = account
418            .with_procedure_threshold(ProcedureName::SendAsset, 2)
419            .expect("threshold updated");
420
421        assert_eq!(
422            updated
423                .procedure_threshold(ProcedureName::SendAsset)
424                .expect("threshold lookup"),
425            Some(2)
426        );
427    }
428
429    #[test]
430    fn with_procedure_threshold_clears_override_when_zero() {
431        let account = build_test_account();
432
433        let updated = account
434            .with_procedure_threshold(ProcedureName::SendAsset, 0)
435            .expect("threshold cleared");
436
437        assert_eq!(
438            updated
439                .procedure_threshold(ProcedureName::SendAsset)
440                .expect("threshold lookup"),
441            None
442        );
443    }
444}