Skip to main content

miden_standards/account/auth/
multisig.rs

1use alloc::collections::BTreeMap;
2use alloc::vec::Vec;
3
4use miden_protocol::Word;
5use miden_protocol::account::component::{
6    AccountComponentCode,
7    AccountComponentMetadata,
8    FeltSchema,
9    SchemaType,
10    StorageSchema,
11    StorageSlotSchema,
12};
13use miden_protocol::account::{
14    AccountComponent,
15    AccountComponentName,
16    AccountProcedureRoot,
17    StorageMap,
18    StorageMapKey,
19    StorageSlot,
20    StorageSlotName,
21};
22use miden_protocol::errors::AccountError;
23use miden_protocol::utils::sync::LazyLock;
24
25use super::{Approver, ApproverSet};
26use crate::account::account_component_code;
27use crate::procedure_root;
28
29account_component_code!(MULTISIG_CODE, "miden-standards-auth-multisig.masp");
30
31// PROCEDURE ROOTS
32// ================================================================================================
33
34/// MASL library namespace used for procedure-root lookups. Distinct from [`AuthMultisig::NAME`],
35/// which mirrors the standards-side MASM module path.
36const MULTISIG_LIBRARY_PATH: &str = "miden::standards::components::auth::multisig";
37
38// Initialize the procedure root of the `set_procedure_threshold` procedure only once. It gates
39// edits to per-procedure overrides, so [`AuthMultisig::new`] uses it to reject overrides that
40// exceed its own threshold.
41procedure_root!(
42    MULTISIG_SET_PROCEDURE_THRESHOLD,
43    MULTISIG_LIBRARY_PATH,
44    AuthMultisig::SET_PROCEDURE_THRESHOLD_PROC_NAME,
45    AuthMultisig::code()
46);
47
48// CONSTANTS
49// ================================================================================================
50
51pub(super) static THRESHOLD_CONFIG_SLOT_NAME: LazyLock<StorageSlotName> = LazyLock::new(|| {
52    StorageSlotName::new("miden::standards::auth::multisig::threshold_config")
53        .expect("storage slot name should be valid")
54});
55
56pub(super) static APPROVER_PUBKEYS_SLOT_NAME: LazyLock<StorageSlotName> = LazyLock::new(|| {
57    StorageSlotName::new("miden::standards::auth::multisig::approver_public_keys")
58        .expect("storage slot name should be valid")
59});
60
61pub(super) static APPROVER_SCHEME_ID_SLOT_NAME: LazyLock<StorageSlotName> = LazyLock::new(|| {
62    StorageSlotName::new("miden::standards::auth::multisig::approver_schemes")
63        .expect("storage slot name should be valid")
64});
65
66pub(super) static EXECUTED_TRANSACTIONS_SLOT_NAME: LazyLock<StorageSlotName> =
67    LazyLock::new(|| {
68        StorageSlotName::new("miden::standards::auth::multisig::executed_transactions")
69            .expect("storage slot name should be valid")
70    });
71
72static PROCEDURE_THRESHOLDS_SLOT_NAME: LazyLock<StorageSlotName> = LazyLock::new(|| {
73    StorageSlotName::new("miden::standards::auth::multisig::procedure_thresholds")
74        .expect("storage slot name should be valid")
75});
76
77// MULTISIG AUTHENTICATION COMPONENT
78// ================================================================================================
79
80/// Configuration for [`AuthMultisig`] component.
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct AuthMultisigConfig {
83    approver_set: ApproverSet,
84    proc_thresholds: BTreeMap<AccountProcedureRoot, u32>,
85}
86
87impl AuthMultisigConfig {
88    /// Creates a new configuration from the given approver set.
89    pub fn new(approver_set: ApproverSet) -> Self {
90        Self {
91            approver_set,
92            proc_thresholds: BTreeMap::new(),
93        }
94    }
95
96    /// Attaches a per-procedure threshold map. Each procedure threshold must be at least 1 and
97    /// at most the number of approvers.
98    pub fn with_proc_thresholds(
99        mut self,
100        proc_thresholds: Vec<(AccountProcedureRoot, u32)>,
101    ) -> Result<Self, AccountError> {
102        let num_approvers = self.approver_set.approvers().len() as u32;
103        let mut thresholds = BTreeMap::new();
104        for (proc_root, threshold) in proc_thresholds {
105            if threshold == 0 {
106                return Err(AccountError::other("procedure threshold must be at least 1"));
107            }
108            if threshold > num_approvers {
109                return Err(AccountError::other(
110                    "procedure threshold cannot be greater than number of approvers",
111                ));
112            }
113            // The map keys the threshold by procedure root, so a repeated root is a caller mistake
114            // rather than a silent overwrite.
115            if thresholds.insert(proc_root, threshold).is_some() {
116                return Err(AccountError::other(
117                    "duplicate procedure roots are not allowed in the procedure threshold map",
118                ));
119            }
120        }
121        self.proc_thresholds = thresholds;
122        Ok(self)
123    }
124
125    pub fn approver_set(&self) -> &ApproverSet {
126        &self.approver_set
127    }
128
129    pub fn approvers(&self) -> &[Approver] {
130        self.approver_set.approvers()
131    }
132
133    pub fn default_threshold(&self) -> u32 {
134        self.approver_set.threshold().get()
135    }
136
137    pub fn proc_thresholds(&self) -> &BTreeMap<AccountProcedureRoot, u32> {
138        &self.proc_thresholds
139    }
140}
141
142/// An [`AccountComponent`] implementing a multisig authentication.
143///
144/// It enforces a threshold of approver signatures for every transaction, with optional
145/// per-procedure threshold overrides.
146///
147/// # Fees
148///
149/// Before authenticating, `auth_tx_multisig` pays the transaction fee via
150/// `miden::standards::auth::multisig::pay_bounded_fee`: it creates a public TX_FEE note (see
151/// [`TxFeeNote`](crate::note::TxFeeNote)) funded from the account's vault, so on
152/// fee-charging chains the account must hold a sufficient balance of the native fee asset. The
153/// payment asset and conversion rate are committed to via the transaction's auth args (see
154/// [`FeeConversionInfo`](super::FeeConversionInfo) and
155/// [`commit_fee_conversion_info`](super::commit_fee_conversion_info)), and the payment must be in
156/// the native fee asset and at most twice the computed fee (`fee::assert_fee_bound`): the rate is
157/// host-supplied and a per-procedure threshold override can authorize a transaction below the
158/// default quorum, so an unbounded payment would let it drain the vault through the fee note. On
159/// chains with a zero verification base fee no note is created. The fee note is created before the
160/// transaction summary, so it is covered by the approver signatures. The auth args word (the
161/// commitment `hash(CONVERSION_INFO || SALT)`) continues to serve as the transaction summary salt;
162/// the uniqueness that replay protection relies on originates from the caller-chosen `SALT`:
163/// distinct salts produce distinct commitments and therefore distinct signed summaries, which
164/// `record_and_assert_new_tx` records and checks.
165///
166/// # Privacy
167///
168/// Approvers using [`AuthScheme::EcdsaK256Keccak`][scheme] disclose their public key and signature
169/// at proving time and therefore do not get public-key privacy; approvers using
170/// [`Falcon512Poseidon2`][falcon] do. See [`Approver`](super::Approver) for details.
171///
172/// [scheme]: miden_protocol::account::auth::AuthScheme::EcdsaK256Keccak
173/// [falcon]: miden_protocol::account::auth::AuthScheme::Falcon512Poseidon2
174///
175/// # Security: private accounts and state withholding
176///
177/// A private account's state lives off-chain; the chain only holds a commitment to it. Whoever
178/// advances the account must share the new state with the other approvers, otherwise those
179/// approvers can no longer reconstruct the state behind the on-chain commitment and are
180/// permanently locked out (and the signers retaining the state can drain its assets). This is a
181/// data-availability problem inherent to private state, not an authorization one: the threshold
182/// controls who *can* advance the state, not whether the resulting state is *shared*. A
183/// per-procedure threshold of one lets a single approver do this; more generally, any quorum
184/// smaller than the full approver set can advance the state and withhold it from the excluded
185/// approvers.
186///
187/// The only configurations that fully prevent withholding are a public account (state is on-chain,
188/// so nothing can be withheld), unanimity (`threshold == number of approvers`, so every approver
189/// signs and therefore sees every state transition), or pairing the multisig with a guardian via
190/// [`AuthGuardedMultisig`](super::AuthGuardedMultisig), whose guardian co-signs every transaction
191/// and forwards the new state. For a private `m`-of-`n` wallet among mutually distrusting
192/// approvers, prefer the guarded variant. The [`create_multisig_wallet`] helper enforces a related
193/// bound: on private accounts it rejects per-procedure thresholds below the default.
194///
195/// [`create_multisig_wallet`]: crate::account::wallets::create_multisig_wallet
196///
197/// # Security: growing the signer set does not re-scale overrides
198///
199/// Per-procedure threshold overrides are absolute signature counts, not ratios. Updating the signer
200/// set (via the `update_signers_and_threshold` account procedure) does not re-scale existing
201/// overrides: the only cross-check is that each override stays `<= num_approvers`, which keeps it
202/// reachable but never raises it. Growing the approver set therefore silently lowers the effective
203/// signing ratio of every override (e.g. a `2`-of-`2` override becomes `2`-of-`n`). To preserve the
204/// intended security level, re-evaluate the affected overrides and, where appropriate, raise them
205/// via `set_procedure_threshold` in the same transaction that grows the signer set.
206///
207/// # Security: a raised override is only as strong as the threshold of `set_procedure_threshold`
208///
209/// An override can demand *more* signatures for a sensitive operation than the default, but that
210/// extra protection is only as strong as the threshold guarding the procedure that can lower it,
211/// `set_procedure_threshold`. That guard is `set_procedure_threshold`'s own override if one is set,
212/// otherwise the default threshold; it is *not* necessarily the default. A group meeting that guard
213/// can strip a stronger override in two transactions: first they lower it, then, in a later
214/// transaction, they run the now-cheaper operation. Two transactions are required because the
215/// signatures needed are read from the state as of the start of the transaction, so a lowered
216/// override only takes effect in the next one.
217///
218/// For example, with 5 signers, a default of 2, `set_procedure_threshold` left at the default, and
219/// a transfer requiring 4: two signers cannot transfer directly, but they can lower the transfer's
220/// override to 2 in one transaction and transfer in the next.
221///
222/// It follows that setting an override higher than the threshold of `set_procedure_threshold`
223/// (which may be the default) is pointless, because the excess signatures can always be removed by
224/// that smaller group. To make a raised override hold, raise `set_procedure_threshold`'s own
225/// threshold to at least that value, so undoing the protection costs as many signatures as the
226/// operation it guards. [`AuthMultisig::new`] enforces this by rejecting any configuration whose
227/// override exceeds the threshold of `set_procedure_threshold`. Note that
228/// `update_signers_and_threshold` can also weaken an override by growing the signer set (see
229/// above), so protect it the same way where relevant.
230#[derive(Debug)]
231pub struct AuthMultisig {
232    config: AuthMultisigConfig,
233}
234
235impl AuthMultisig {
236    /// The name of the component.
237    pub const NAME: &'static str = "miden::standards::auth::multisig";
238
239    /// The name of the procedure that edits per-procedure threshold overrides.
240    const SET_PROCEDURE_THRESHOLD_PROC_NAME: &'static str = "set_procedure_threshold";
241
242    /// Returns the canonical [`AccountComponentName`] of this component.
243    pub const fn name() -> AccountComponentName {
244        AccountComponentName::from_static_str(Self::NAME)
245    }
246
247    /// Returns the [`AccountComponentCode`] of this component.
248    pub fn code() -> &'static AccountComponentCode {
249        &MULTISIG_CODE
250    }
251
252    /// Returns the procedure root of the `set_procedure_threshold` account procedure.
253    pub fn set_procedure_threshold_root() -> AccountProcedureRoot {
254        *MULTISIG_SET_PROCEDURE_THRESHOLD
255    }
256
257    /// Creates a new [`AuthMultisig`] component from the provided configuration.
258    ///
259    /// # Errors
260    ///
261    /// Returns an error if a per-procedure override exceeds the threshold that guards
262    /// `set_procedure_threshold` (its own override if set, otherwise the default threshold). Such
263    /// an override is not enforceable, since a group meeting that lower threshold can strip it
264    /// via `set_procedure_threshold`; see the type-level security notes.
265    pub fn new(config: AuthMultisigConfig) -> Result<Self, AccountError> {
266        // The threshold that must be met to edit overrides via `set_procedure_threshold`: its own
267        // override if configured, otherwise the default threshold.
268        let setter_threshold = config
269            .proc_thresholds()
270            .get(&Self::set_procedure_threshold_root())
271            .copied()
272            .unwrap_or_else(|| config.default_threshold());
273
274        for &threshold in config.proc_thresholds().values() {
275            if threshold > setter_threshold {
276                return Err(AccountError::other(format!(
277                    "per-procedure threshold override of {threshold} exceeds the threshold of \
278                     {setter_threshold} that guards set_procedure_threshold; such an override can \
279                     be removed by a smaller quorum. Raise the set_procedure_threshold override to \
280                     at least {threshold} to make it enforceable"
281                )));
282            }
283        }
284
285        Ok(Self { config })
286    }
287
288    /// Returns the [`StorageSlotName`] where the threshold configuration is stored.
289    pub fn threshold_config_slot() -> &'static StorageSlotName {
290        &THRESHOLD_CONFIG_SLOT_NAME
291    }
292
293    /// Returns the [`StorageSlotName`] where the approver public keys are stored.
294    pub fn approver_public_keys_slot() -> &'static StorageSlotName {
295        &APPROVER_PUBKEYS_SLOT_NAME
296    }
297
298    // Returns the [`StorageSlotName`] where the approver scheme IDs are stored.
299    pub fn approver_scheme_ids_slot() -> &'static StorageSlotName {
300        &APPROVER_SCHEME_ID_SLOT_NAME
301    }
302
303    /// Returns the [`StorageSlotName`] where the executed transactions are stored.
304    pub fn executed_transactions_slot() -> &'static StorageSlotName {
305        &EXECUTED_TRANSACTIONS_SLOT_NAME
306    }
307
308    /// Returns the [`StorageSlotName`] where the procedure thresholds are stored.
309    pub fn procedure_thresholds_slot() -> &'static StorageSlotName {
310        &PROCEDURE_THRESHOLDS_SLOT_NAME
311    }
312
313    /// Returns the storage slot schema for the threshold configuration slot.
314    pub fn threshold_config_slot_schema() -> (StorageSlotName, StorageSlotSchema) {
315        (
316            Self::threshold_config_slot().clone(),
317            StorageSlotSchema::value(
318                "Threshold configuration",
319                [
320                    FeltSchema::u32("threshold"),
321                    FeltSchema::u32("num_approvers"),
322                    FeltSchema::new_void(),
323                    FeltSchema::new_void(),
324                ],
325            ),
326        )
327    }
328
329    /// Returns the storage slot schema for the approver public keys slot.
330    pub fn approver_public_keys_slot_schema() -> (StorageSlotName, StorageSlotSchema) {
331        (
332            Self::approver_public_keys_slot().clone(),
333            StorageSlotSchema::map(
334                "Approver public keys",
335                SchemaType::u32(),
336                SchemaType::pub_key(),
337            ),
338        )
339    }
340
341    // Returns the storage slot schema for the approver scheme IDs slot.
342    pub fn approver_auth_scheme_slot_schema() -> (StorageSlotName, StorageSlotSchema) {
343        (
344            Self::approver_scheme_ids_slot().clone(),
345            StorageSlotSchema::map(
346                "Approver scheme IDs",
347                SchemaType::u32(),
348                SchemaType::auth_scheme(),
349            ),
350        )
351    }
352
353    /// Returns the storage slot schema for the executed transactions slot.
354    pub fn executed_transactions_slot_schema() -> (StorageSlotName, StorageSlotSchema) {
355        (
356            Self::executed_transactions_slot().clone(),
357            StorageSlotSchema::map(
358                "Executed transactions",
359                SchemaType::native_word(),
360                SchemaType::native_word(),
361            ),
362        )
363    }
364
365    /// Returns the storage slot schema for the procedure thresholds slot.
366    pub fn procedure_thresholds_slot_schema() -> (StorageSlotName, StorageSlotSchema) {
367        (
368            Self::procedure_thresholds_slot().clone(),
369            StorageSlotSchema::map(
370                "Procedure thresholds",
371                SchemaType::native_word(),
372                SchemaType::u32(),
373            ),
374        )
375    }
376
377    /// Returns the [`AccountComponentMetadata`] for this component.
378    pub fn component_metadata() -> AccountComponentMetadata {
379        let storage_schema = StorageSchema::new([
380            Self::threshold_config_slot_schema(),
381            Self::approver_public_keys_slot_schema(),
382            Self::approver_auth_scheme_slot_schema(),
383            Self::executed_transactions_slot_schema(),
384            Self::procedure_thresholds_slot_schema(),
385        ])
386        .expect("storage schema should be valid");
387
388        AccountComponentMetadata::new(Self::NAME)
389            .with_description("Multisig authentication component using hybrid signature schemes")
390            .with_storage_schema(storage_schema)
391    }
392}
393
394impl From<AuthMultisig> for AccountComponent {
395    fn from(multisig: AuthMultisig) -> Self {
396        let mut storage_slots = Vec::with_capacity(5);
397
398        // Threshold config slot (value: [threshold, num_approvers, 0, 0])
399        let num_approvers = multisig.config.approvers().len() as u32;
400        storage_slots.push(StorageSlot::with_value(
401            AuthMultisig::threshold_config_slot().clone(),
402            Word::from([multisig.config.default_threshold(), num_approvers, 0, 0]),
403        ));
404
405        // Approver public keys slot (map)
406        let map_entries = multisig.config.approvers().iter().enumerate().map(|(i, approver)| {
407            (StorageMapKey::from_index(i as u32), Word::from(approver.pub_key()))
408        });
409
410        // Safe to unwrap because we know that the map keys are unique.
411        storage_slots.push(StorageSlot::with_map(
412            AuthMultisig::approver_public_keys_slot().clone(),
413            StorageMap::with_entries(map_entries).unwrap(),
414        ));
415
416        // Approver scheme IDs slot (map): [index, 0, 0, 0] => [scheme_id, 0, 0, 0]
417        let scheme_id_entries =
418            multisig.config.approvers().iter().enumerate().map(|(i, approver)| {
419                (
420                    StorageMapKey::from_index(i as u32),
421                    Word::from([approver.auth_scheme() as u32, 0, 0, 0]),
422                )
423            });
424
425        storage_slots.push(StorageSlot::with_map(
426            AuthMultisig::approver_scheme_ids_slot().clone(),
427            StorageMap::with_entries(scheme_id_entries).unwrap(),
428        ));
429
430        // Executed transactions slot (map)
431        let executed_transactions = StorageMap::default();
432        storage_slots.push(StorageSlot::with_map(
433            AuthMultisig::executed_transactions_slot().clone(),
434            executed_transactions,
435        ));
436
437        // Procedure thresholds slot (map: PROC_ROOT -> threshold)
438        let proc_threshold_roots = StorageMap::with_entries(
439            multisig.config.proc_thresholds().iter().map(|(proc_root, threshold)| {
440                (StorageMapKey::from_raw(proc_root.as_word()), Word::from([*threshold, 0, 0, 0]))
441            }),
442        )
443        .unwrap();
444        storage_slots.push(StorageSlot::with_map(
445            AuthMultisig::procedure_thresholds_slot().clone(),
446            proc_threshold_roots,
447        ));
448
449        let metadata = AuthMultisig::component_metadata();
450
451        AccountComponent::new(AuthMultisig::code().clone(), storage_slots, metadata).expect(
452            "Multisig auth component should satisfy the requirements of a valid account component",
453        )
454    }
455}
456
457// TESTS
458// ================================================================================================
459
460#[cfg(test)]
461mod tests {
462    use alloc::string::ToString;
463
464    use miden_protocol::Word;
465    use miden_protocol::account::auth::AuthSecretKey;
466    use miden_protocol::account::{AccountBuilder, auth};
467
468    use super::*;
469    use crate::account::wallets::BasicWallet;
470
471    /// Test multisig component setup with various configurations
472    #[test]
473    fn test_multisig_component_setup() {
474        // Create test secret keys
475        let sec_key_1 = AuthSecretKey::new_falcon512_poseidon2();
476        let sec_key_2 = AuthSecretKey::new_falcon512_poseidon2();
477        let sec_key_3 = AuthSecretKey::new_falcon512_poseidon2();
478
479        // Create approvers list for multisig config
480        let approvers = vec![
481            Approver::new(sec_key_1.public_key().to_commitment(), sec_key_1.auth_scheme()),
482            Approver::new(sec_key_2.public_key().to_commitment(), sec_key_2.auth_scheme()),
483            Approver::new(sec_key_3.public_key().to_commitment(), sec_key_3.auth_scheme()),
484        ];
485
486        let threshold = 2u32;
487
488        // Create multisig component
489        let approver_set =
490            ApproverSet::new(approvers.clone(), threshold).expect("invalid approver set");
491        let multisig_component = AuthMultisig::new(AuthMultisigConfig::new(approver_set))
492            .expect("multisig component creation failed");
493
494        // Build account with multisig component
495        let account = AccountBuilder::new([0; 32])
496            .with_component(multisig_component)
497            .with_component(BasicWallet)
498            .build()
499            .expect("account building failed");
500
501        // Verify config slot: [threshold, num_approvers, 0, 0]
502        let config_slot = account
503            .storage()
504            .get_item(AuthMultisig::threshold_config_slot())
505            .expect("config storage slot access failed");
506        assert_eq!(config_slot, Word::from([threshold, approvers.len() as u32, 0, 0]));
507
508        // Verify approver pub keys slot
509        for (i, approver) in approvers.iter().enumerate() {
510            let stored_pub_key = account
511                .storage()
512                .get_map_item(
513                    AuthMultisig::approver_public_keys_slot(),
514                    StorageMapKey::from_index(i as u32),
515                )
516                .expect("approver public key storage map access failed");
517            assert_eq!(stored_pub_key, Word::from(approver.pub_key()));
518        }
519
520        // Verify approver scheme IDs slot
521        for (i, approver) in approvers.iter().enumerate() {
522            let stored_scheme_id = account
523                .storage()
524                .get_map_item(
525                    AuthMultisig::approver_scheme_ids_slot(),
526                    StorageMapKey::from_index(i as u32),
527                )
528                .expect("approver scheme ID storage map access failed");
529            assert_eq!(stored_scheme_id, Word::from([approver.auth_scheme() as u32, 0, 0, 0]));
530        }
531    }
532
533    /// Test multisig component with minimum threshold (1 of 1)
534    #[test]
535    fn test_multisig_component_minimum_threshold() {
536        let pub_key = AuthSecretKey::new_ecdsa_k256_keccak().public_key().to_commitment();
537        let approvers = vec![Approver::new(pub_key, auth::AuthScheme::EcdsaK256Keccak)];
538        let threshold = 1u32;
539
540        let approver_set =
541            ApproverSet::new(approvers.clone(), threshold).expect("invalid approver set");
542        let multisig_component = AuthMultisig::new(AuthMultisigConfig::new(approver_set))
543            .expect("multisig component creation failed");
544
545        let account = AccountBuilder::new([0; 32])
546            .with_component(multisig_component)
547            .with_component(BasicWallet)
548            .build()
549            .expect("account building failed");
550
551        // Verify storage layout
552        let config_slot = account
553            .storage()
554            .get_item(AuthMultisig::threshold_config_slot())
555            .expect("config storage slot access failed");
556        assert_eq!(config_slot, Word::from([threshold, approvers.len() as u32, 0, 0]));
557
558        let stored_pub_key = account
559            .storage()
560            .get_map_item(AuthMultisig::approver_public_keys_slot(), StorageMapKey::from_index(0))
561            .expect("approver pub keys storage map access failed");
562        assert_eq!(stored_pub_key, Word::from(pub_key));
563
564        let stored_scheme_id = account
565            .storage()
566            .get_map_item(AuthMultisig::approver_scheme_ids_slot(), StorageMapKey::from_index(0))
567            .expect("approver scheme IDs storage map access failed");
568        assert_eq!(
569            stored_scheme_id,
570            Word::from([auth::AuthScheme::EcdsaK256Keccak as u32, 0, 0, 0])
571        );
572    }
573
574    /// Test that a per-procedure threshold exceeding the number of approvers is rejected.
575    #[test]
576    fn test_proc_threshold_too_high() {
577        let pub_key = AuthSecretKey::new_ecdsa_k256_keccak().public_key().to_commitment();
578        let approvers = vec![Approver::new(pub_key, auth::AuthScheme::EcdsaK256Keccak)];
579        let approver_set = ApproverSet::new(approvers, 1).expect("invalid approver set");
580
581        let result = AuthMultisigConfig::new(approver_set)
582            .with_proc_thresholds(vec![(BasicWallet::receive_asset_root(), 2)]);
583        assert!(
584            result
585                .unwrap_err()
586                .to_string()
587                .contains("procedure threshold cannot be greater than number of approvers")
588        );
589    }
590
591    /// Test that an override exceeding the threshold guarding `set_procedure_threshold` (here the
592    /// default, since it has no override of its own) is rejected by `AuthMultisig::new`, because a
593    /// smaller quorum could lower it.
594    #[test]
595    fn test_proc_threshold_above_set_procedure_threshold_rejected() {
596        let approvers = vec![
597            Approver::new(
598                AuthSecretKey::new_ecdsa_k256_keccak().public_key().to_commitment(),
599                auth::AuthScheme::EcdsaK256Keccak,
600            ),
601            Approver::new(
602                AuthSecretKey::new_ecdsa_k256_keccak().public_key().to_commitment(),
603                auth::AuthScheme::EcdsaK256Keccak,
604            ),
605            Approver::new(
606                AuthSecretKey::new_ecdsa_k256_keccak().public_key().to_commitment(),
607                auth::AuthScheme::EcdsaK256Keccak,
608            ),
609        ];
610        let approver_set = ApproverSet::new(approvers, 2).expect("invalid approver set");
611
612        // The override (3) is within num_approvers, so `with_proc_thresholds` accepts it, but it
613        // exceeds the default threshold (2) that guards `set_procedure_threshold`.
614        let config = AuthMultisigConfig::new(approver_set)
615            .with_proc_thresholds(vec![(BasicWallet::receive_asset_root(), 3)])
616            .expect("an override within num_approvers is accepted by with_proc_thresholds");
617
618        let err = AuthMultisig::new(config).unwrap_err();
619        assert!(err.to_string().contains("exceeds the threshold"));
620    }
621}