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}