1use 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
13const 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#[derive(Debug, Clone)]
30pub struct MultisigAccount {
31 account: Account,
32}
33
34impl MultisigAccount {
35 pub fn new(account: Account) -> Self {
37 Self { account }
38 }
39
40 pub fn id(&self) -> AccountId {
42 self.account.id()
43 }
44
45 pub fn nonce(&self) -> u64 {
47 self.account.nonce().as_canonical_u64()
48 }
49
50 pub fn commitment(&self) -> Word {
52 self.account.to_commitment()
53 }
54
55 pub fn inner(&self) -> &Account {
57 &self.account
58 }
59
60 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 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 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 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 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 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 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 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 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 pub fn is_cosigner(&self, commitment: &Word) -> bool {
191 self.cosigner_commitments().contains(commitment)
192 }
193
194 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 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 note_type: miden_protocol::note::NoteType::Public,
343 })
344 .expect("threshold"),
345 1
346 );
347 assert_eq!(
348 account
349 .effective_threshold_for_transaction(&TransactionType::ConsumeNotes {
350 note_ids: vec![NoteId::from_raw(word(5))],
351 metadata_version: None,
352 notes: Vec::new(),
353 })
354 .expect("threshold"),
355 2
356 );
357 assert_eq!(
358 account
359 .effective_threshold_for_transaction(&TransactionType::AddCosigner {
360 new_commitment: word(10),
361 })
362 .expect("threshold"),
363 3
364 );
365 assert_eq!(
366 account
367 .effective_threshold_for_transaction(&TransactionType::RemoveCosigner {
368 commitment: word(2),
369 })
370 .expect("threshold"),
371 3
372 );
373 assert_eq!(
374 account
375 .effective_threshold_for_transaction(&TransactionType::UpdateSigners {
376 new_threshold: 2,
377 signer_commitments: vec![word(1), word(2), word(3)],
378 })
379 .expect("threshold"),
380 3
381 );
382 assert_eq!(
383 account
384 .effective_threshold_for_transaction(&TransactionType::SwitchGuardian {
385 new_endpoint: "http://new-guardian.example.com".to_string(),
386 new_commitment: word(11),
387 })
388 .expect("threshold"),
389 1
390 );
391 assert_eq!(
392 account
393 .effective_threshold_for_transaction(&TransactionType::Custom)
394 .expect("threshold"),
395 2,
396 "custom proposals use the account default threshold"
397 );
398 }
399
400 #[test]
401 fn cosigner_commitments_reads_openzeppelin_signer_map() {
402 let account = build_account_with_signer_slots(vec![word(11), word(12)]);
403
404 assert_eq!(account.cosigner_commitments(), vec![word(11), word(12)]);
405 }
406
407 #[test]
408 fn cosigner_commitments_returns_empty_when_openzeppelin_signer_map_is_empty() {
409 let account = build_account_with_signer_slots(Vec::new());
410
411 assert!(account.cosigner_commitments().is_empty());
412 }
413
414 #[test]
415 fn with_procedure_threshold_updates_existing_override() {
416 let account = build_test_account();
417
418 let updated = account
419 .with_procedure_threshold(ProcedureName::SendAsset, 2)
420 .expect("threshold updated");
421
422 assert_eq!(
423 updated
424 .procedure_threshold(ProcedureName::SendAsset)
425 .expect("threshold lookup"),
426 Some(2)
427 );
428 }
429
430 #[test]
431 fn with_procedure_threshold_clears_override_when_zero() {
432 let account = build_test_account();
433
434 let updated = account
435 .with_procedure_threshold(ProcedureName::SendAsset, 0)
436 .expect("threshold cleared");
437
438 assert_eq!(
439 updated
440 .procedure_threshold(ProcedureName::SendAsset)
441 .expect("threshold lookup"),
442 None
443 );
444 }
445}