Skip to main content

miden_standards/account/auth/
multisig.rs

1use alloc::collections::BTreeSet;
2use alloc::vec::Vec;
3
4use miden_protocol::Word;
5use miden_protocol::account::auth::{AuthScheme, PublicKeyCommitment};
6use miden_protocol::account::component::{
7    AccountComponentMetadata,
8    FeltSchema,
9    SchemaType,
10    StorageSchema,
11    StorageSlotSchema,
12};
13use miden_protocol::account::{
14    AccountComponent,
15    StorageMap,
16    StorageMapKey,
17    StorageSlot,
18    StorageSlotName,
19};
20use miden_protocol::errors::AccountError;
21use miden_protocol::utils::sync::LazyLock;
22
23use crate::account::components::multisig_library;
24
25static THRESHOLD_CONFIG_SLOT_NAME: LazyLock<StorageSlotName> = LazyLock::new(|| {
26    StorageSlotName::new("miden::standards::auth::multisig::threshold_config")
27        .expect("storage slot name should be valid")
28});
29
30static APPROVER_PUBKEYS_SLOT_NAME: LazyLock<StorageSlotName> = LazyLock::new(|| {
31    StorageSlotName::new("miden::standards::auth::multisig::approver_public_keys")
32        .expect("storage slot name should be valid")
33});
34
35static APPROVER_SCHEME_ID_SLOT_NAME: LazyLock<StorageSlotName> = LazyLock::new(|| {
36    StorageSlotName::new("miden::standards::auth::multisig::approver_schemes")
37        .expect("storage slot name should be valid")
38});
39
40static EXECUTED_TRANSACTIONS_SLOT_NAME: LazyLock<StorageSlotName> = LazyLock::new(|| {
41    StorageSlotName::new("miden::standards::auth::multisig::executed_transactions")
42        .expect("storage slot name should be valid")
43});
44
45static PROCEDURE_THRESHOLDS_SLOT_NAME: LazyLock<StorageSlotName> = LazyLock::new(|| {
46    StorageSlotName::new("miden::standards::auth::multisig::procedure_thresholds")
47        .expect("storage slot name should be valid")
48});
49
50// MULTISIG AUTHENTICATION COMPONENT
51// ================================================================================================
52
53/// Configuration for [`AuthMultisig`] component.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct AuthMultisigConfig {
56    approvers: Vec<(PublicKeyCommitment, AuthScheme)>,
57    default_threshold: u32,
58    proc_thresholds: Vec<(Word, u32)>,
59}
60
61impl AuthMultisigConfig {
62    /// Creates a new configuration with the given approvers and a default threshold.
63    ///
64    /// The `default_threshold` must be at least 1 and at most the number of approvers.
65    pub fn new(
66        approvers: Vec<(PublicKeyCommitment, AuthScheme)>,
67        default_threshold: u32,
68    ) -> Result<Self, AccountError> {
69        if default_threshold == 0 {
70            return Err(AccountError::other("threshold must be at least 1"));
71        }
72        if default_threshold > approvers.len() as u32 {
73            return Err(AccountError::other(
74                "threshold cannot be greater than number of approvers",
75            ));
76        }
77
78        // Check for duplicate approvers
79        let unique_approvers: BTreeSet<_> = approvers.iter().map(|(pk, _)| pk).collect();
80
81        if unique_approvers.len() != approvers.len() {
82            return Err(AccountError::other("duplicate approver public keys are not allowed"));
83        }
84
85        Ok(Self {
86            approvers,
87            default_threshold,
88            proc_thresholds: vec![],
89        })
90    }
91
92    /// Attaches a per-procedure threshold map. Each procedure threshold must be at least 1 and
93    /// at most the number of approvers.
94    pub fn with_proc_thresholds(
95        mut self,
96        proc_thresholds: Vec<(Word, u32)>,
97    ) -> Result<Self, AccountError> {
98        for (_, threshold) in &proc_thresholds {
99            if *threshold == 0 {
100                return Err(AccountError::other("procedure threshold must be at least 1"));
101            }
102            if *threshold > self.approvers.len() as u32 {
103                return Err(AccountError::other(
104                    "procedure threshold cannot be greater than number of approvers",
105                ));
106            }
107        }
108        self.proc_thresholds = proc_thresholds;
109        Ok(self)
110    }
111
112    pub fn approvers(&self) -> &[(PublicKeyCommitment, AuthScheme)] {
113        &self.approvers
114    }
115
116    pub fn default_threshold(&self) -> u32 {
117        self.default_threshold
118    }
119
120    pub fn proc_thresholds(&self) -> &[(Word, u32)] {
121        &self.proc_thresholds
122    }
123}
124
125/// An [`AccountComponent`] implementing a multisig based on ECDSA signatures.
126///
127/// It enforces a threshold of approver signatures for every transaction, with optional
128/// per-procedure thresholds overrides. Non-uniform thresholds (especially a threshold of one)
129/// should be used with caution for private multisig accounts, as a single approver could withhold
130///  the new state from other approvers, effectively locking them out.
131///
132/// The storage layout is:
133/// - Slot 0(value): [threshold, num_approvers, 0, 0]
134/// - Slot 1(map): A map with approver public keys (index -> pubkey)
135/// - Slot 2(map): A map with approver scheme ids (index -> scheme_id)
136/// - Slot 3(map): A map which stores executed transactions
137/// - Slot 4(map): A map which stores procedure thresholds (PROC_ROOT -> threshold)
138///
139/// This component supports all account types.
140#[derive(Debug)]
141pub struct AuthMultisig {
142    config: AuthMultisigConfig,
143}
144
145impl AuthMultisig {
146    /// The name of the component.
147    pub const NAME: &'static str = "miden::auth::multisig";
148
149    /// Creates a new [`AuthMultisig`] component from the provided configuration.
150    pub fn new(config: AuthMultisigConfig) -> Result<Self, AccountError> {
151        Ok(Self { config })
152    }
153
154    /// Returns the [`StorageSlotName`] where the threshold configuration is stored.
155    pub fn threshold_config_slot() -> &'static StorageSlotName {
156        &THRESHOLD_CONFIG_SLOT_NAME
157    }
158
159    /// Returns the [`StorageSlotName`] where the approver public keys are stored.
160    pub fn approver_public_keys_slot() -> &'static StorageSlotName {
161        &APPROVER_PUBKEYS_SLOT_NAME
162    }
163
164    // Returns the [`StorageSlotName`] where the approver scheme IDs are stored.
165    pub fn approver_scheme_ids_slot() -> &'static StorageSlotName {
166        &APPROVER_SCHEME_ID_SLOT_NAME
167    }
168
169    /// Returns the [`StorageSlotName`] where the executed transactions are stored.
170    pub fn executed_transactions_slot() -> &'static StorageSlotName {
171        &EXECUTED_TRANSACTIONS_SLOT_NAME
172    }
173
174    /// Returns the [`StorageSlotName`] where the procedure thresholds are stored.
175    pub fn procedure_thresholds_slot() -> &'static StorageSlotName {
176        &PROCEDURE_THRESHOLDS_SLOT_NAME
177    }
178
179    /// Returns the storage slot schema for the threshold configuration slot.
180    pub fn threshold_config_slot_schema() -> (StorageSlotName, StorageSlotSchema) {
181        (
182            Self::threshold_config_slot().clone(),
183            StorageSlotSchema::value(
184                "Threshold configuration",
185                [
186                    FeltSchema::u32("threshold"),
187                    FeltSchema::u32("num_approvers"),
188                    FeltSchema::new_void(),
189                    FeltSchema::new_void(),
190                ],
191            ),
192        )
193    }
194
195    /// Returns the storage slot schema for the approver public keys slot.
196    pub fn approver_public_keys_slot_schema() -> (StorageSlotName, StorageSlotSchema) {
197        (
198            Self::approver_public_keys_slot().clone(),
199            StorageSlotSchema::map(
200                "Approver public keys",
201                SchemaType::u32(),
202                SchemaType::pub_key(),
203            ),
204        )
205    }
206
207    // Returns the storage slot schema for the approver scheme IDs slot.
208    pub fn approver_auth_scheme_slot_schema() -> (StorageSlotName, StorageSlotSchema) {
209        (
210            Self::approver_scheme_ids_slot().clone(),
211            StorageSlotSchema::map(
212                "Approver scheme IDs",
213                SchemaType::u32(),
214                SchemaType::auth_scheme(),
215            ),
216        )
217    }
218
219    /// Returns the storage slot schema for the executed transactions slot.
220    pub fn executed_transactions_slot_schema() -> (StorageSlotName, StorageSlotSchema) {
221        (
222            Self::executed_transactions_slot().clone(),
223            StorageSlotSchema::map(
224                "Executed transactions",
225                SchemaType::native_word(),
226                SchemaType::native_word(),
227            ),
228        )
229    }
230
231    /// Returns the storage slot schema for the procedure thresholds slot.
232    pub fn procedure_thresholds_slot_schema() -> (StorageSlotName, StorageSlotSchema) {
233        (
234            Self::procedure_thresholds_slot().clone(),
235            StorageSlotSchema::map(
236                "Procedure thresholds",
237                SchemaType::native_word(),
238                SchemaType::u32(),
239            ),
240        )
241    }
242}
243
244impl From<AuthMultisig> for AccountComponent {
245    fn from(multisig: AuthMultisig) -> Self {
246        let mut storage_slots = Vec::with_capacity(5);
247
248        // Threshold config slot (value: [threshold, num_approvers, 0, 0])
249        let num_approvers = multisig.config.approvers().len() as u32;
250        storage_slots.push(StorageSlot::with_value(
251            AuthMultisig::threshold_config_slot().clone(),
252            Word::from([multisig.config.default_threshold(), num_approvers, 0, 0]),
253        ));
254
255        // Approver public keys slot (map)
256        let map_entries =
257            multisig.config.approvers().iter().enumerate().map(|(i, (pub_key, _))| {
258                (StorageMapKey::from_index(i as u32), Word::from(*pub_key))
259            });
260
261        // Safe to unwrap because we know that the map keys are unique.
262        storage_slots.push(StorageSlot::with_map(
263            AuthMultisig::approver_public_keys_slot().clone(),
264            StorageMap::with_entries(map_entries).unwrap(),
265        ));
266
267        // Approver scheme IDs slot (map): [index, 0, 0, 0] => [scheme_id, 0, 0, 0]
268        let scheme_id_entries =
269            multisig.config.approvers().iter().enumerate().map(|(i, (_, auth_scheme))| {
270                (StorageMapKey::from_index(i as u32), Word::from([*auth_scheme as u32, 0, 0, 0]))
271            });
272
273        storage_slots.push(StorageSlot::with_map(
274            AuthMultisig::approver_scheme_ids_slot().clone(),
275            StorageMap::with_entries(scheme_id_entries).unwrap(),
276        ));
277
278        // Executed transactions slot (map)
279        let executed_transactions = StorageMap::default();
280        storage_slots.push(StorageSlot::with_map(
281            AuthMultisig::executed_transactions_slot().clone(),
282            executed_transactions,
283        ));
284
285        // Procedure thresholds slot (map: PROC_ROOT -> threshold)
286        let proc_threshold_roots = StorageMap::with_entries(
287            multisig.config.proc_thresholds().iter().map(|(proc_root, threshold)| {
288                (StorageMapKey::from_raw(*proc_root), Word::from([*threshold, 0, 0, 0]))
289            }),
290        )
291        .unwrap();
292        storage_slots.push(StorageSlot::with_map(
293            AuthMultisig::procedure_thresholds_slot().clone(),
294            proc_threshold_roots,
295        ));
296
297        let storage_schema = StorageSchema::new([
298            AuthMultisig::threshold_config_slot_schema(),
299            AuthMultisig::approver_public_keys_slot_schema(),
300            AuthMultisig::approver_auth_scheme_slot_schema(),
301            AuthMultisig::executed_transactions_slot_schema(),
302            AuthMultisig::procedure_thresholds_slot_schema(),
303        ])
304        .expect("storage schema should be valid");
305
306        let metadata = AccountComponentMetadata::new(AuthMultisig::NAME)
307            .with_description("Multisig authentication component using hybrid signature schemes")
308            .with_supports_all_types()
309            .with_storage_schema(storage_schema);
310
311        AccountComponent::new(multisig_library(), storage_slots, metadata).expect(
312            "Multisig auth component should satisfy the requirements of a valid account component",
313        )
314    }
315}
316
317#[cfg(test)]
318mod tests {
319    use alloc::string::ToString;
320
321    use miden_protocol::Word;
322    use miden_protocol::account::auth::AuthSecretKey;
323    use miden_protocol::account::{AccountBuilder, auth};
324
325    use super::*;
326    use crate::account::wallets::BasicWallet;
327
328    /// Test multisig component setup with various configurations
329    #[test]
330    fn test_multisig_component_setup() {
331        // Create test secret keys
332        let sec_key_1 = AuthSecretKey::new_falcon512_rpo();
333        let sec_key_2 = AuthSecretKey::new_falcon512_rpo();
334        let sec_key_3 = AuthSecretKey::new_falcon512_rpo();
335
336        // Create approvers list for multisig config
337        let approvers = vec![
338            (sec_key_1.public_key().to_commitment(), sec_key_1.auth_scheme()),
339            (sec_key_2.public_key().to_commitment(), sec_key_2.auth_scheme()),
340            (sec_key_3.public_key().to_commitment(), sec_key_3.auth_scheme()),
341        ];
342
343        let threshold = 2u32;
344
345        // Create multisig component
346        let multisig_component = AuthMultisig::new(
347            AuthMultisigConfig::new(approvers.clone(), threshold).expect("invalid multisig config"),
348        )
349        .expect("multisig component creation failed");
350
351        // Build account with multisig component
352        let account = AccountBuilder::new([0; 32])
353            .with_auth_component(multisig_component)
354            .with_component(BasicWallet)
355            .build()
356            .expect("account building failed");
357
358        // Verify config slot: [threshold, num_approvers, 0, 0]
359        let config_slot = account
360            .storage()
361            .get_item(AuthMultisig::threshold_config_slot())
362            .expect("config storage slot access failed");
363        assert_eq!(config_slot, Word::from([threshold, approvers.len() as u32, 0, 0]));
364
365        // Verify approver pub keys slot
366        for (i, (expected_pub_key, _)) in approvers.iter().enumerate() {
367            let stored_pub_key = account
368                .storage()
369                .get_map_item(
370                    AuthMultisig::approver_public_keys_slot(),
371                    Word::from([i as u32, 0, 0, 0]),
372                )
373                .expect("approver public key storage map access failed");
374            assert_eq!(stored_pub_key, Word::from(*expected_pub_key));
375        }
376
377        // Verify approver scheme IDs slot
378        for (i, (_, expected_auth_scheme)) in approvers.iter().enumerate() {
379            let stored_scheme_id = account
380                .storage()
381                .get_map_item(
382                    AuthMultisig::approver_scheme_ids_slot(),
383                    Word::from([i as u32, 0, 0, 0]),
384                )
385                .expect("approver scheme ID storage map access failed");
386            assert_eq!(stored_scheme_id, Word::from([*expected_auth_scheme as u32, 0, 0, 0]));
387        }
388    }
389
390    /// Test multisig component with minimum threshold (1 of 1)
391    #[test]
392    fn test_multisig_component_minimum_threshold() {
393        let pub_key = AuthSecretKey::new_ecdsa_k256_keccak().public_key().to_commitment();
394        let approvers = vec![(pub_key, auth::AuthScheme::EcdsaK256Keccak)];
395        let threshold = 1u32;
396
397        let multisig_component = AuthMultisig::new(
398            AuthMultisigConfig::new(approvers.clone(), threshold).expect("invalid multisig config"),
399        )
400        .expect("multisig component creation failed");
401
402        let account = AccountBuilder::new([0; 32])
403            .with_auth_component(multisig_component)
404            .with_component(BasicWallet)
405            .build()
406            .expect("account building failed");
407
408        // Verify storage layout
409        let config_slot = account
410            .storage()
411            .get_item(AuthMultisig::threshold_config_slot())
412            .expect("config storage slot access failed");
413        assert_eq!(config_slot, Word::from([threshold, approvers.len() as u32, 0, 0]));
414
415        let stored_pub_key = account
416            .storage()
417            .get_map_item(AuthMultisig::approver_public_keys_slot(), Word::from([0u32, 0, 0, 0]))
418            .expect("approver pub keys storage map access failed");
419        assert_eq!(stored_pub_key, Word::from(pub_key));
420
421        let stored_scheme_id = account
422            .storage()
423            .get_map_item(AuthMultisig::approver_scheme_ids_slot(), Word::from([0u32, 0, 0, 0]))
424            .expect("approver scheme IDs storage map access failed");
425        assert_eq!(
426            stored_scheme_id,
427            Word::from([auth::AuthScheme::EcdsaK256Keccak as u32, 0, 0, 0])
428        );
429    }
430
431    /// Test multisig component error cases
432    #[test]
433    fn test_multisig_component_error_cases() {
434        let pub_key = AuthSecretKey::new_ecdsa_k256_keccak().public_key().to_commitment();
435        let approvers = vec![(pub_key, auth::AuthScheme::EcdsaK256Keccak)];
436
437        // Test threshold = 0 (should fail)
438        let result = AuthMultisigConfig::new(approvers.clone(), 0);
439        assert!(result.unwrap_err().to_string().contains("threshold must be at least 1"));
440
441        // Test threshold > number of approvers (should fail)
442        let result = AuthMultisigConfig::new(approvers, 2);
443        assert!(
444            result
445                .unwrap_err()
446                .to_string()
447                .contains("threshold cannot be greater than number of approvers")
448        );
449    }
450
451    /// Test multisig component with duplicate approvers (should fail)
452    #[test]
453    fn test_multisig_component_duplicate_approvers() {
454        // Create secret keys for approvers
455        let sec_key_1 = AuthSecretKey::new_ecdsa_k256_keccak();
456        let sec_key_2 = AuthSecretKey::new_ecdsa_k256_keccak();
457
458        // Create approvers list with duplicate public keys
459        let approvers = vec![
460            (sec_key_1.public_key().to_commitment(), sec_key_1.auth_scheme()),
461            (sec_key_1.public_key().to_commitment(), sec_key_1.auth_scheme()),
462            (sec_key_2.public_key().to_commitment(), sec_key_2.auth_scheme()),
463        ];
464
465        let result = AuthMultisigConfig::new(approvers, 2);
466        assert!(
467            result
468                .unwrap_err()
469                .to_string()
470                .contains("duplicate approver public keys are not allowed")
471        );
472    }
473}