Skip to main content

miden_standards/account/auth/
multisig.rs

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