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