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 })
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}