Skip to main content

miden_standards/account/auth/
multisig.rs

1use alloc::collections::BTreeMap;
2use alloc::vec::Vec;
3use core::num::NonZeroU32;
4
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::block::BlockNumber;
23use miden_protocol::crypto::SequentialCommit;
24use miden_protocol::errors::AccountError;
25use miden_protocol::utils::sync::LazyLock;
26use miden_protocol::{EMPTY_WORD, Felt, WORD_SIZE, Word, ZERO};
27
28use super::{Approver, ApproverSet, FeeConversionInfo};
29use crate::account::account_component_code;
30use crate::procedure_root;
31
32account_component_code!(MULTISIG_CODE, "miden-standards-auth-multisig.masp");
33
34// PROCEDURE ROOTS
35// ================================================================================================
36
37/// MASL library namespace used for procedure-root lookups. Distinct from [`AuthMultisig::NAME`],
38/// which mirrors the standards-side MASM module path.
39const MULTISIG_LIBRARY_PATH: &str = "miden::standards::components::auth::multisig";
40
41// Initialize the procedure root of the `set_procedure_threshold` procedure only once. It gates
42// edits to per-procedure overrides, so [`AuthMultisig::new`] uses it to reject overrides that
43// exceed its own threshold.
44procedure_root!(
45    MULTISIG_SET_PROCEDURE_THRESHOLD,
46    MULTISIG_LIBRARY_PATH,
47    AuthMultisig::SET_PROCEDURE_THRESHOLD_PROC_NAME,
48    AuthMultisig::code()
49);
50
51// CONSTANTS
52// ================================================================================================
53
54pub(super) static THRESHOLD_CONFIG_SLOT_NAME: LazyLock<StorageSlotName> = LazyLock::new(|| {
55    StorageSlotName::new("miden::standards::auth::multisig::threshold_config")
56        .expect("storage slot name should be valid")
57});
58
59pub(super) static APPROVER_PUBKEYS_SLOT_NAME: LazyLock<StorageSlotName> = LazyLock::new(|| {
60    StorageSlotName::new("miden::standards::auth::multisig::approver_public_keys")
61        .expect("storage slot name should be valid")
62});
63
64pub(super) static APPROVER_SCHEME_ID_SLOT_NAME: LazyLock<StorageSlotName> = LazyLock::new(|| {
65    StorageSlotName::new("miden::standards::auth::multisig::approver_schemes")
66        .expect("storage slot name should be valid")
67});
68
69pub(super) static EXECUTED_TRANSACTIONS_SLOT_NAME: LazyLock<StorageSlotName> =
70    LazyLock::new(|| {
71        StorageSlotName::new("miden::standards::auth::multisig::executed_transactions")
72            .expect("storage slot name should be valid")
73    });
74
75static PROCEDURE_THRESHOLDS_SLOT_NAME: LazyLock<StorageSlotName> = LazyLock::new(|| {
76    StorageSlotName::new("miden::standards::auth::multisig::procedure_thresholds")
77        .expect("storage slot name should be valid")
78});
79
80// MULTISIG AUTHENTICATION COMPONENT
81// ================================================================================================
82
83/// Configuration for [`AuthMultisig`] component.
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub struct AuthMultisigConfig {
86    approver_set: ApproverSet,
87    proc_thresholds: BTreeMap<AccountProcedureRoot, u32>,
88}
89
90impl AuthMultisigConfig {
91    /// Creates a new configuration from the given approver set.
92    pub fn new(approver_set: ApproverSet) -> Self {
93        Self {
94            approver_set,
95            proc_thresholds: BTreeMap::new(),
96        }
97    }
98
99    /// Attaches a per-procedure threshold map. Each procedure threshold must be at least 1 and
100    /// at most the number of approvers.
101    pub fn with_proc_thresholds(
102        mut self,
103        proc_thresholds: Vec<(AccountProcedureRoot, u32)>,
104    ) -> Result<Self, AccountError> {
105        let num_approvers = self.approver_set.approvers().len() as u32;
106        let mut thresholds = BTreeMap::new();
107        for (proc_root, threshold) in proc_thresholds {
108            if threshold == 0 {
109                return Err(AccountError::other("procedure threshold must be at least 1"));
110            }
111            if threshold > num_approvers {
112                return Err(AccountError::other(
113                    "procedure threshold cannot be greater than number of approvers",
114                ));
115            }
116            // The map keys the threshold by procedure root, so a repeated root is a caller mistake
117            // rather than a silent overwrite.
118            if thresholds.insert(proc_root, threshold).is_some() {
119                return Err(AccountError::other(
120                    "duplicate procedure roots are not allowed in the procedure threshold map",
121                ));
122            }
123        }
124        self.proc_thresholds = thresholds;
125        Ok(self)
126    }
127
128    pub fn approver_set(&self) -> &ApproverSet {
129        &self.approver_set
130    }
131
132    pub fn approvers(&self) -> &[Approver] {
133        self.approver_set.approvers()
134    }
135
136    pub fn default_threshold(&self) -> u32 {
137        self.approver_set.threshold().get()
138    }
139
140    pub fn proc_thresholds(&self) -> &BTreeMap<AccountProcedureRoot, u32> {
141        &self.proc_thresholds
142    }
143}
144
145/// An [`AccountComponent`] implementing a multisig authentication.
146///
147/// It enforces a threshold of approver signatures for every transaction, with optional
148/// per-procedure threshold overrides.
149///
150/// # Auth args
151///
152/// The transaction's auth args are the commitment to [`MultisigAuthArgs`].
153///
154/// # Fees
155///
156/// Before authenticating, `auth_tx_multisig` pays the transaction fee via
157/// `miden::standards::fee::pay_fee`: it creates a public TX_FEE note (see
158/// [`TxFeeNote`](crate::note::TxFeeNote)) funded from the account's vault, so on
159/// fee-charging chains the account must hold a sufficient balance of the native fee asset. The
160/// conversion info from the auth args must name the reference block's fee asset at rate 1/1 (see
161/// [`FeeConversionInfo::one_to_one`](super::FeeConversionInfo::one_to_one)). On chains with a
162/// zero verification base fee no note is created. The fee note is created before the transaction
163/// summary, so it is covered by the approver signatures.
164///
165/// # Expiration
166///
167/// Two independent expirations apply, and the earlier one ends the transaction's validity.
168///
169/// The approval expiration defines how long the signature stays usable. It is set with
170/// [`MultisigAuthArgs::with_approval_expiration_delta`] and is measured from the block the
171/// summary binds, and is bound by the summary itself.
172///
173/// The transaction's own expiration delta is a freshness bound: a procedure that reads mutable
174/// foreign state through FPI caps how stale that read may be.
175///
176/// Neither is set by default: the signatures of an approval without an expiration stay usable for
177/// as long as the summary they cover can be reproduced.
178///
179/// # Privacy
180///
181/// Approvers using [`AuthScheme::EcdsaK256Keccak`][scheme] disclose their public key and signature
182/// at proving time and therefore do not get public-key privacy; approvers using
183/// [`Falcon512Poseidon2`][falcon] do. See [`Approver`](super::Approver) for details.
184///
185/// [scheme]: miden_protocol::account::auth::AuthScheme::EcdsaK256Keccak
186/// [falcon]: miden_protocol::account::auth::AuthScheme::Falcon512Poseidon2
187///
188/// # Security: private accounts and state withholding
189///
190/// A private account's state lives off-chain; the chain only holds a commitment to it. Whoever
191/// advances the account must share the new state with the other approvers, otherwise those
192/// approvers can no longer reconstruct the state behind the on-chain commitment and are
193/// permanently locked out (and the signers retaining the state can drain its assets). This is a
194/// data-availability problem inherent to private state, not an authorization one: the threshold
195/// controls who *can* advance the state, not whether the resulting state is *shared*. A
196/// per-procedure threshold of one lets a single approver do this; more generally, any quorum
197/// smaller than the full approver set can advance the state and withhold it from the excluded
198/// approvers.
199///
200/// The only configurations that fully prevent withholding are a public account (state is on-chain,
201/// so nothing can be withheld), unanimity (`threshold == number of approvers`, so every approver
202/// signs and therefore sees every state transition), or pairing the multisig with a guardian via
203/// [`AuthGuardedMultisig`](super::AuthGuardedMultisig), whose guardian co-signs every transaction
204/// and forwards the new state. For a private `m`-of-`n` wallet among mutually distrusting
205/// approvers, prefer the guarded variant. The [`create_multisig_wallet`] helper enforces a related
206/// bound: on private accounts it rejects per-procedure thresholds below the default.
207///
208/// [`create_multisig_wallet`]: crate::account::wallets::create_multisig_wallet
209///
210/// # Security: growing the signer set does not re-scale overrides
211///
212/// Per-procedure threshold overrides are absolute signature counts, not ratios. Updating the signer
213/// set (via the `update_signers_and_threshold` account procedure) does not re-scale existing
214/// overrides: the only cross-check is that each override stays `<= num_approvers`, which keeps it
215/// reachable but never raises it. Growing the approver set therefore silently lowers the effective
216/// signing ratio of every override (e.g. a `2`-of-`2` override becomes `2`-of-`n`). To preserve the
217/// intended security level, re-evaluate the affected overrides and, where appropriate, raise them
218/// via `set_procedure_threshold` in the same transaction that grows the signer set.
219///
220/// # Security: a raised override is only as strong as the threshold of `set_procedure_threshold`
221///
222/// An override can demand *more* signatures for a sensitive operation than the default, but that
223/// extra protection is only as strong as the threshold guarding the procedure that can lower it,
224/// `set_procedure_threshold`. That guard is `set_procedure_threshold`'s own override if one is set,
225/// otherwise the default threshold; it is *not* necessarily the default. A group meeting that guard
226/// can strip a stronger override in two transactions: first they lower it, then, in a later
227/// transaction, they run the now-cheaper operation. Two transactions are required because the
228/// signatures needed are read from the state as of the start of the transaction, so a lowered
229/// override only takes effect in the next one.
230///
231/// For example, with 5 signers, a default of 2, `set_procedure_threshold` left at the default, and
232/// a transfer requiring 4: two signers cannot transfer directly, but they can lower the transfer's
233/// override to 2 in one transaction and transfer in the next.
234///
235/// It follows that setting an override higher than the threshold of `set_procedure_threshold`
236/// (which may be the default) is pointless, because the excess signatures can always be removed by
237/// that smaller group. To make a raised override hold, raise `set_procedure_threshold`'s own
238/// threshold to at least that value, so undoing the protection costs as many signatures as the
239/// operation it guards. [`AuthMultisig::new`] enforces this by rejecting any configuration whose
240/// override exceeds the threshold of `set_procedure_threshold`. Note that
241/// `update_signers_and_threshold` can also weaken an override by growing the signer set (see
242/// above), so protect it the same way where relevant.
243///
244/// # Security: a lowered override authorizes changes to the procedure's output notes
245///
246/// The transaction threshold is derived only from the called account procedures, but a
247/// transaction script can also change output notes without calling one: it can add attachments to
248/// any output note, and add assets that were removed from the vault but not yet placed in a note.
249/// These changes do not raise the threshold, so an override below the default lets that smaller
250/// group of approvers also change the notes the procedure creates.
251///
252/// For example, a procedure with an override of 1 creates a note with a fixed recipient and
253/// asset. A single approver can still add a secret attachment that the recipient cannot
254/// reconstruct, so the recipient cannot consume the note. Or the approver can add a
255/// [`NetworkAccountTarget`](crate::note::NetworkAccountTarget) attachment, which makes the account
256/// fund a fee sponsorship for a network account the approver chooses.
257///
258/// To prevent this, a procedure with a lowered override should seal every note it creates with
259/// `miden::protocol::output_note::seal`, after it adds its own assets and attachments. A sealed
260/// note rejects further assets and attachments. The auth procedure cannot check this, so the
261/// protection holds only when the procedure itself seals its notes.
262#[derive(Debug)]
263pub struct AuthMultisig {
264    config: AuthMultisigConfig,
265}
266
267impl AuthMultisig {
268    /// The name of the component.
269    pub const NAME: &'static str = "miden::standards::auth::multisig";
270
271    /// The name of the procedure that edits per-procedure threshold overrides.
272    const SET_PROCEDURE_THRESHOLD_PROC_NAME: &'static str = "set_procedure_threshold";
273
274    /// Returns the canonical [`AccountComponentName`] of this component.
275    pub const fn name() -> AccountComponentName {
276        AccountComponentName::from_static_str(Self::NAME)
277    }
278
279    /// Returns the [`AccountComponentCode`] of this component.
280    pub fn code() -> &'static AccountComponentCode {
281        &MULTISIG_CODE
282    }
283
284    /// Returns the procedure root of the `set_procedure_threshold` account procedure.
285    pub fn set_procedure_threshold_root() -> AccountProcedureRoot {
286        *MULTISIG_SET_PROCEDURE_THRESHOLD
287    }
288
289    /// Creates a new [`AuthMultisig`] component from the provided configuration.
290    ///
291    /// # Errors
292    ///
293    /// Returns an error if a per-procedure override exceeds the threshold that guards
294    /// `set_procedure_threshold` (its own override if set, otherwise the default threshold). Such
295    /// an override is not enforceable, since a group meeting that lower threshold can strip it
296    /// via `set_procedure_threshold`; see the type-level security notes.
297    pub fn new(config: AuthMultisigConfig) -> Result<Self, AccountError> {
298        // The threshold that must be met to edit overrides via `set_procedure_threshold`: its own
299        // override if configured, otherwise the default threshold.
300        let setter_threshold = config
301            .proc_thresholds()
302            .get(&Self::set_procedure_threshold_root())
303            .copied()
304            .unwrap_or_else(|| config.default_threshold());
305
306        for &threshold in config.proc_thresholds().values() {
307            if threshold > setter_threshold {
308                return Err(AccountError::other(format!(
309                    "per-procedure threshold override of {threshold} exceeds the threshold of \
310                     {setter_threshold} that guards set_procedure_threshold; such an override can \
311                     be removed by a smaller quorum. Raise the set_procedure_threshold override to \
312                     at least {threshold} to make it enforceable"
313                )));
314            }
315        }
316
317        Ok(Self { config })
318    }
319
320    /// Returns the [`StorageSlotName`] where the threshold configuration is stored.
321    pub fn threshold_config_slot() -> &'static StorageSlotName {
322        &THRESHOLD_CONFIG_SLOT_NAME
323    }
324
325    /// Returns the [`StorageSlotName`] where the approver public keys are stored.
326    pub fn approver_public_keys_slot() -> &'static StorageSlotName {
327        &APPROVER_PUBKEYS_SLOT_NAME
328    }
329
330    // Returns the [`StorageSlotName`] where the approver scheme IDs are stored.
331    pub fn approver_scheme_ids_slot() -> &'static StorageSlotName {
332        &APPROVER_SCHEME_ID_SLOT_NAME
333    }
334
335    /// Returns the [`StorageSlotName`] where the executed transactions are stored.
336    pub fn executed_transactions_slot() -> &'static StorageSlotName {
337        &EXECUTED_TRANSACTIONS_SLOT_NAME
338    }
339
340    /// Returns the [`StorageSlotName`] where the procedure thresholds are stored.
341    pub fn procedure_thresholds_slot() -> &'static StorageSlotName {
342        &PROCEDURE_THRESHOLDS_SLOT_NAME
343    }
344
345    /// Returns the storage slot schema for the threshold configuration slot.
346    pub fn threshold_config_slot_schema() -> (StorageSlotName, StorageSlotSchema) {
347        (
348            Self::threshold_config_slot().clone(),
349            StorageSlotSchema::value(
350                "Threshold configuration",
351                [
352                    FeltSchema::u32("threshold"),
353                    FeltSchema::u32("num_approvers"),
354                    FeltSchema::new_void(),
355                    FeltSchema::new_void(),
356                ],
357            ),
358        )
359    }
360
361    /// Returns the storage slot schema for the approver public keys slot.
362    pub fn approver_public_keys_slot_schema() -> (StorageSlotName, StorageSlotSchema) {
363        (
364            Self::approver_public_keys_slot().clone(),
365            StorageSlotSchema::map(
366                "Approver public keys",
367                SchemaType::u32(),
368                SchemaType::pub_key(),
369            ),
370        )
371    }
372
373    // Returns the storage slot schema for the approver scheme IDs slot.
374    pub fn approver_auth_scheme_slot_schema() -> (StorageSlotName, StorageSlotSchema) {
375        (
376            Self::approver_scheme_ids_slot().clone(),
377            StorageSlotSchema::map(
378                "Approver scheme IDs",
379                SchemaType::u32(),
380                SchemaType::auth_scheme(),
381            ),
382        )
383    }
384
385    /// Returns the storage slot schema for the executed transactions slot.
386    pub fn executed_transactions_slot_schema() -> (StorageSlotName, StorageSlotSchema) {
387        (
388            Self::executed_transactions_slot().clone(),
389            StorageSlotSchema::map(
390                "Executed transactions",
391                SchemaType::native_word(),
392                SchemaType::native_word(),
393            ),
394        )
395    }
396
397    /// Returns the storage slot schema for the procedure thresholds slot.
398    pub fn procedure_thresholds_slot_schema() -> (StorageSlotName, StorageSlotSchema) {
399        (
400            Self::procedure_thresholds_slot().clone(),
401            StorageSlotSchema::map(
402                "Procedure thresholds",
403                SchemaType::native_word(),
404                SchemaType::u32(),
405            ),
406        )
407    }
408
409    /// Returns the [`AccountComponentMetadata`] for this component.
410    pub fn component_metadata() -> AccountComponentMetadata {
411        let storage_schema = StorageSchema::new([
412            Self::threshold_config_slot_schema(),
413            Self::approver_public_keys_slot_schema(),
414            Self::approver_auth_scheme_slot_schema(),
415            Self::executed_transactions_slot_schema(),
416            Self::procedure_thresholds_slot_schema(),
417        ])
418        .expect("storage schema should be valid");
419
420        AccountComponentMetadata::new(Self::NAME)
421            .with_description("Multisig authentication component using hybrid signature schemes")
422            .with_storage_schema(storage_schema)
423    }
424}
425
426impl From<AuthMultisig> for AccountComponent {
427    fn from(multisig: AuthMultisig) -> Self {
428        let mut storage_slots = Vec::with_capacity(5);
429
430        // Threshold config slot (value: [threshold, num_approvers, 0, 0])
431        let num_approvers = multisig.config.approvers().len() as u32;
432        storage_slots.push(StorageSlot::with_value(
433            AuthMultisig::threshold_config_slot().clone(),
434            Word::from([multisig.config.default_threshold(), num_approvers, 0, 0]),
435        ));
436
437        // Approver public keys slot (map)
438        let map_entries = multisig.config.approvers().iter().enumerate().map(|(i, approver)| {
439            (StorageMapKey::from_index(i as u32), Word::from(approver.pub_key()))
440        });
441
442        // Safe to unwrap because we know that the map keys are unique.
443        storage_slots.push(StorageSlot::with_map(
444            AuthMultisig::approver_public_keys_slot().clone(),
445            StorageMap::with_entries(map_entries).unwrap(),
446        ));
447
448        // Approver scheme IDs slot (map): [index, 0, 0, 0] => [scheme_id, 0, 0, 0]
449        let scheme_id_entries =
450            multisig.config.approvers().iter().enumerate().map(|(i, approver)| {
451                (
452                    StorageMapKey::from_index(i as u32),
453                    Word::from([approver.auth_scheme() as u32, 0, 0, 0]),
454                )
455            });
456
457        storage_slots.push(StorageSlot::with_map(
458            AuthMultisig::approver_scheme_ids_slot().clone(),
459            StorageMap::with_entries(scheme_id_entries).unwrap(),
460        ));
461
462        // Executed transactions slot (map)
463        let executed_transactions = StorageMap::default();
464        storage_slots.push(StorageSlot::with_map(
465            AuthMultisig::executed_transactions_slot().clone(),
466            executed_transactions,
467        ));
468
469        // Procedure thresholds slot (map: PROC_ROOT -> threshold)
470        let proc_threshold_roots = StorageMap::with_entries(
471            multisig.config.proc_thresholds().iter().map(|(proc_root, threshold)| {
472                (StorageMapKey::from_raw(proc_root.as_word()), Word::from([*threshold, 0, 0, 0]))
473            }),
474        )
475        .unwrap();
476        storage_slots.push(StorageSlot::with_map(
477            AuthMultisig::procedure_thresholds_slot().clone(),
478            proc_threshold_roots,
479        ));
480
481        let metadata = AuthMultisig::component_metadata();
482
483        AccountComponent::new(AuthMultisig::code().clone(), storage_slots, metadata).expect(
484            "Multisig auth component should satisfy the requirements of a valid account component",
485        )
486    }
487}
488
489// MULTISIG AUTH ARGS
490// ================================================================================================
491
492/// The inputs the multisig authentication components receive through the transaction's auth args.
493///
494/// ```text
495/// AUTH_ARGS: [BLOCK_WORD, SALT, CONVERSION_INFO]
496/// ```
497///
498/// where `BLOCK_WORD` is `[bound_block_num, approval_expiration_block_num, 0, 0]`.
499#[derive(Debug, Clone, Copy, PartialEq, Eq)]
500pub struct MultisigAuthArgs {
501    bound_block_num: BlockNumber,
502    approval_expiration_block_num: Option<BlockNumber>,
503    salt: Word,
504    conversion_info: Option<FeeConversionInfo>,
505}
506
507impl MultisigAuthArgs {
508    /// Creates new multisig auth args binding the summary to the given block.
509    ///
510    /// The signers approve a transaction summary that commits to `bound_block_num`, so the party
511    /// executing the transaction must pass the same block number, no matter how far the chain has
512    /// advanced since. The block must be at or before the transaction's reference block and must
513    /// be tracked by the transaction's partial blockchain, since that is the only way the kernel
514    /// can read its commitment.
515    ///
516    /// The approval does not expire unless [`Self::with_approval_expiration_delta`] sets an
517    /// expiration.
518    ///
519    /// `salt` is bound by the transaction summary and is what makes otherwise identical
520    /// transactions distinguishable, which is what the replay protection of the multisig
521    /// components relies on. It should be chosen at random.
522    pub fn new(bound_block_num: BlockNumber, salt: Word) -> Self {
523        Self {
524            bound_block_num,
525            approval_expiration_block_num: None,
526            salt,
527            conversion_info: None,
528        }
529    }
530
531    /// Returns new multisig auth args whose approval expires `delta` blocks after the bound block.
532    ///
533    /// The transaction must be included by block `bound_block_num + delta`. The expiration is bound
534    /// by the transaction summary, so the party executing the transaction can neither shorten nor
535    /// extend it.
536    ///
537    /// # Errors
538    ///
539    /// Returns an error if `bound_block_num + delta` exceeds [`BlockNumber::MAX`].
540    pub fn with_approval_expiration_delta(
541        mut self,
542        delta: NonZeroU32,
543    ) -> Result<Self, AccountError> {
544        let expiration_block_num =
545            self.bound_block_num.as_u32().checked_add(delta.get()).ok_or_else(|| {
546                AccountError::other(
547                    "approval expiration block number exceeds the maximum block number",
548                )
549            })?;
550
551        self.approval_expiration_block_num = Some(BlockNumber::from(expiration_block_num));
552        Ok(self)
553    }
554
555    /// Returns new multisig auth args carrying the conversion info the fee payment needs.
556    ///
557    /// Must be [`FeeConversionInfo::one_to_one`] built with the reference block's fee faucet.
558    /// Anything else, or no conversion info at all, aborts on chains that charge a non-zero
559    /// verification base fee.
560    #[must_use]
561    pub fn with_conversion_info(mut self, conversion_info: FeeConversionInfo) -> Self {
562        self.conversion_info = Some(conversion_info);
563        self
564    }
565
566    // PUBLIC ACCESSORS
567    // --------------------------------------------------------------------------------------------
568
569    /// Returns the number of the block the transaction summary binds.
570    pub fn bound_block_num(&self) -> BlockNumber {
571        self.bound_block_num
572    }
573
574    /// Returns the first reference block at which the approvers' signatures are no longer valid,
575    /// or `None` if the approval does not expire.
576    pub fn approval_expiration_block_num(&self) -> Option<BlockNumber> {
577        self.approval_expiration_block_num
578    }
579
580    /// Returns the salt bound by the transaction summary.
581    pub fn salt(&self) -> Word {
582        self.salt
583    }
584
585    /// Returns the fee conversion info, or `None` if none was committed - in which case the fee
586    /// payment aborts on fee-charging chains.
587    pub fn conversion_info(&self) -> Option<FeeConversionInfo> {
588        self.conversion_info
589    }
590}
591
592impl SequentialCommit for MultisigAuthArgs {
593    type Commitment = Word;
594
595    fn to_elements(&self) -> Vec<Felt> {
596        let conversion_info = self.conversion_info.map_or(EMPTY_WORD, |info| info.to_word());
597        let approval_expiration = self.approval_expiration_block_num.map_or(Felt::ZERO, Felt::from);
598
599        let mut elements = Vec::with_capacity(3 * WORD_SIZE);
600        elements.extend([Felt::from(self.bound_block_num), approval_expiration, ZERO, ZERO]);
601        elements.extend(self.salt.iter());
602        elements.extend(conversion_info.iter());
603        elements
604    }
605}
606
607// TESTS
608// ================================================================================================
609
610#[cfg(test)]
611mod tests {
612    use alloc::string::ToString;
613
614    use miden_protocol::account::auth::AuthSecretKey;
615    use miden_protocol::account::{AccountBuilder, auth};
616
617    use super::*;
618    use crate::account::wallets::BasicWallet;
619
620    /// Test multisig component setup with various configurations
621    #[test]
622    fn test_multisig_component_setup() {
623        // Create test secret keys
624        let sec_key_1 = AuthSecretKey::new_falcon512_poseidon2();
625        let sec_key_2 = AuthSecretKey::new_falcon512_poseidon2();
626        let sec_key_3 = AuthSecretKey::new_falcon512_poseidon2();
627
628        // Create approvers list for multisig config
629        let approvers = vec![
630            Approver::new(sec_key_1.public_key().to_commitment(), sec_key_1.auth_scheme()),
631            Approver::new(sec_key_2.public_key().to_commitment(), sec_key_2.auth_scheme()),
632            Approver::new(sec_key_3.public_key().to_commitment(), sec_key_3.auth_scheme()),
633        ];
634
635        let threshold = 2u32;
636
637        // Create multisig component
638        let approver_set =
639            ApproverSet::new(approvers.clone(), threshold).expect("invalid approver set");
640        let multisig_component = AuthMultisig::new(AuthMultisigConfig::new(approver_set))
641            .expect("multisig component creation failed");
642
643        // Build account with multisig component
644        let account = AccountBuilder::new([0; 32])
645            .with_component(multisig_component)
646            .with_component(BasicWallet)
647            .build()
648            .expect("account building failed");
649
650        // Verify config slot: [threshold, num_approvers, 0, 0]
651        let config_slot = account
652            .storage()
653            .get_item(AuthMultisig::threshold_config_slot())
654            .expect("config storage slot access failed");
655        assert_eq!(config_slot, Word::from([threshold, approvers.len() as u32, 0, 0]));
656
657        // Verify approver pub keys slot
658        for (i, approver) in approvers.iter().enumerate() {
659            let stored_pub_key = account
660                .storage()
661                .get_map_item(
662                    AuthMultisig::approver_public_keys_slot(),
663                    StorageMapKey::from_index(i as u32),
664                )
665                .expect("approver public key storage map access failed");
666            assert_eq!(stored_pub_key, Word::from(approver.pub_key()));
667        }
668
669        // Verify approver scheme IDs slot
670        for (i, approver) in approvers.iter().enumerate() {
671            let stored_scheme_id = account
672                .storage()
673                .get_map_item(
674                    AuthMultisig::approver_scheme_ids_slot(),
675                    StorageMapKey::from_index(i as u32),
676                )
677                .expect("approver scheme ID storage map access failed");
678            assert_eq!(stored_scheme_id, Word::from([approver.auth_scheme() as u32, 0, 0, 0]));
679        }
680    }
681
682    /// Test multisig component with minimum threshold (1 of 1)
683    #[test]
684    fn test_multisig_component_minimum_threshold() {
685        let pub_key = AuthSecretKey::new_ecdsa_k256_keccak().public_key().to_commitment();
686        let approvers = vec![Approver::new(pub_key, auth::AuthScheme::EcdsaK256Keccak)];
687        let threshold = 1u32;
688
689        let approver_set =
690            ApproverSet::new(approvers.clone(), threshold).expect("invalid approver set");
691        let multisig_component = AuthMultisig::new(AuthMultisigConfig::new(approver_set))
692            .expect("multisig component creation failed");
693
694        let account = AccountBuilder::new([0; 32])
695            .with_component(multisig_component)
696            .with_component(BasicWallet)
697            .build()
698            .expect("account building failed");
699
700        // Verify storage layout
701        let config_slot = account
702            .storage()
703            .get_item(AuthMultisig::threshold_config_slot())
704            .expect("config storage slot access failed");
705        assert_eq!(config_slot, Word::from([threshold, approvers.len() as u32, 0, 0]));
706
707        let stored_pub_key = account
708            .storage()
709            .get_map_item(AuthMultisig::approver_public_keys_slot(), StorageMapKey::from_index(0))
710            .expect("approver pub keys storage map access failed");
711        assert_eq!(stored_pub_key, Word::from(pub_key));
712
713        let stored_scheme_id = account
714            .storage()
715            .get_map_item(AuthMultisig::approver_scheme_ids_slot(), StorageMapKey::from_index(0))
716            .expect("approver scheme IDs storage map access failed");
717        assert_eq!(
718            stored_scheme_id,
719            Word::from([auth::AuthScheme::EcdsaK256Keccak as u32, 0, 0, 0])
720        );
721    }
722
723    /// Test that a per-procedure threshold exceeding the number of approvers is rejected.
724    #[test]
725    fn test_proc_threshold_too_high() {
726        let pub_key = AuthSecretKey::new_ecdsa_k256_keccak().public_key().to_commitment();
727        let approvers = vec![Approver::new(pub_key, auth::AuthScheme::EcdsaK256Keccak)];
728        let approver_set = ApproverSet::new(approvers, 1).expect("invalid approver set");
729
730        let result = AuthMultisigConfig::new(approver_set)
731            .with_proc_thresholds(vec![(BasicWallet::receive_asset_root(), 2)]);
732        assert!(
733            result
734                .unwrap_err()
735                .to_string()
736                .contains("procedure threshold cannot be greater than number of approvers")
737        );
738    }
739
740    /// Test that an override exceeding the threshold guarding `set_procedure_threshold` (here the
741    /// default, since it has no override of its own) is rejected by `AuthMultisig::new`, because a
742    /// smaller quorum could lower it.
743    #[test]
744    fn test_proc_threshold_above_set_procedure_threshold_rejected() {
745        let approvers = vec![
746            Approver::new(
747                AuthSecretKey::new_ecdsa_k256_keccak().public_key().to_commitment(),
748                auth::AuthScheme::EcdsaK256Keccak,
749            ),
750            Approver::new(
751                AuthSecretKey::new_ecdsa_k256_keccak().public_key().to_commitment(),
752                auth::AuthScheme::EcdsaK256Keccak,
753            ),
754            Approver::new(
755                AuthSecretKey::new_ecdsa_k256_keccak().public_key().to_commitment(),
756                auth::AuthScheme::EcdsaK256Keccak,
757            ),
758        ];
759        let approver_set = ApproverSet::new(approvers, 2).expect("invalid approver set");
760
761        // The override (3) is within num_approvers, so `with_proc_thresholds` accepts it, but it
762        // exceeds the default threshold (2) that guards `set_procedure_threshold`.
763        let config = AuthMultisigConfig::new(approver_set)
764            .with_proc_thresholds(vec![(BasicWallet::receive_asset_root(), 3)])
765            .expect("an override within num_approvers is accepted by with_proc_thresholds");
766
767        let err = AuthMultisig::new(config).unwrap_err();
768        assert!(err.to_string().contains("exceeds the threshold"));
769    }
770}