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#[derive(Debug)]
244pub struct AuthMultisig {
245 config: AuthMultisigConfig,
246}
247
248impl AuthMultisig {
249 /// The name of the component.
250 pub const NAME: &'static str = "miden::standards::auth::multisig";
251
252 /// The name of the procedure that edits per-procedure threshold overrides.
253 const SET_PROCEDURE_THRESHOLD_PROC_NAME: &'static str = "set_procedure_threshold";
254
255 /// Returns the canonical [`AccountComponentName`] of this component.
256 pub const fn name() -> AccountComponentName {
257 AccountComponentName::from_static_str(Self::NAME)
258 }
259
260 /// Returns the [`AccountComponentCode`] of this component.
261 pub fn code() -> &'static AccountComponentCode {
262 &MULTISIG_CODE
263 }
264
265 /// Returns the procedure root of the `set_procedure_threshold` account procedure.
266 pub fn set_procedure_threshold_root() -> AccountProcedureRoot {
267 *MULTISIG_SET_PROCEDURE_THRESHOLD
268 }
269
270 /// Creates a new [`AuthMultisig`] component from the provided configuration.
271 ///
272 /// # Errors
273 ///
274 /// Returns an error if a per-procedure override exceeds the threshold that guards
275 /// `set_procedure_threshold` (its own override if set, otherwise the default threshold). Such
276 /// an override is not enforceable, since a group meeting that lower threshold can strip it
277 /// via `set_procedure_threshold`; see the type-level security notes.
278 pub fn new(config: AuthMultisigConfig) -> Result<Self, AccountError> {
279 // The threshold that must be met to edit overrides via `set_procedure_threshold`: its own
280 // override if configured, otherwise the default threshold.
281 let setter_threshold = config
282 .proc_thresholds()
283 .get(&Self::set_procedure_threshold_root())
284 .copied()
285 .unwrap_or_else(|| config.default_threshold());
286
287 for &threshold in config.proc_thresholds().values() {
288 if threshold > setter_threshold {
289 return Err(AccountError::other(format!(
290 "per-procedure threshold override of {threshold} exceeds the threshold of \
291 {setter_threshold} that guards set_procedure_threshold; such an override can \
292 be removed by a smaller quorum. Raise the set_procedure_threshold override to \
293 at least {threshold} to make it enforceable"
294 )));
295 }
296 }
297
298 Ok(Self { config })
299 }
300
301 /// Returns the [`StorageSlotName`] where the threshold configuration is stored.
302 pub fn threshold_config_slot() -> &'static StorageSlotName {
303 &THRESHOLD_CONFIG_SLOT_NAME
304 }
305
306 /// Returns the [`StorageSlotName`] where the approver public keys are stored.
307 pub fn approver_public_keys_slot() -> &'static StorageSlotName {
308 &APPROVER_PUBKEYS_SLOT_NAME
309 }
310
311 // Returns the [`StorageSlotName`] where the approver scheme IDs are stored.
312 pub fn approver_scheme_ids_slot() -> &'static StorageSlotName {
313 &APPROVER_SCHEME_ID_SLOT_NAME
314 }
315
316 /// Returns the [`StorageSlotName`] where the executed transactions are stored.
317 pub fn executed_transactions_slot() -> &'static StorageSlotName {
318 &EXECUTED_TRANSACTIONS_SLOT_NAME
319 }
320
321 /// Returns the [`StorageSlotName`] where the procedure thresholds are stored.
322 pub fn procedure_thresholds_slot() -> &'static StorageSlotName {
323 &PROCEDURE_THRESHOLDS_SLOT_NAME
324 }
325
326 /// Returns the storage slot schema for the threshold configuration slot.
327 pub fn threshold_config_slot_schema() -> (StorageSlotName, StorageSlotSchema) {
328 (
329 Self::threshold_config_slot().clone(),
330 StorageSlotSchema::value(
331 "Threshold configuration",
332 [
333 FeltSchema::u32("threshold"),
334 FeltSchema::u32("num_approvers"),
335 FeltSchema::new_void(),
336 FeltSchema::new_void(),
337 ],
338 ),
339 )
340 }
341
342 /// Returns the storage slot schema for the approver public keys slot.
343 pub fn approver_public_keys_slot_schema() -> (StorageSlotName, StorageSlotSchema) {
344 (
345 Self::approver_public_keys_slot().clone(),
346 StorageSlotSchema::map(
347 "Approver public keys",
348 SchemaType::u32(),
349 SchemaType::pub_key(),
350 ),
351 )
352 }
353
354 // Returns the storage slot schema for the approver scheme IDs slot.
355 pub fn approver_auth_scheme_slot_schema() -> (StorageSlotName, StorageSlotSchema) {
356 (
357 Self::approver_scheme_ids_slot().clone(),
358 StorageSlotSchema::map(
359 "Approver scheme IDs",
360 SchemaType::u32(),
361 SchemaType::auth_scheme(),
362 ),
363 )
364 }
365
366 /// Returns the storage slot schema for the executed transactions slot.
367 pub fn executed_transactions_slot_schema() -> (StorageSlotName, StorageSlotSchema) {
368 (
369 Self::executed_transactions_slot().clone(),
370 StorageSlotSchema::map(
371 "Executed transactions",
372 SchemaType::native_word(),
373 SchemaType::native_word(),
374 ),
375 )
376 }
377
378 /// Returns the storage slot schema for the procedure thresholds slot.
379 pub fn procedure_thresholds_slot_schema() -> (StorageSlotName, StorageSlotSchema) {
380 (
381 Self::procedure_thresholds_slot().clone(),
382 StorageSlotSchema::map(
383 "Procedure thresholds",
384 SchemaType::native_word(),
385 SchemaType::u32(),
386 ),
387 )
388 }
389
390 /// Returns the [`AccountComponentMetadata`] for this component.
391 pub fn component_metadata() -> AccountComponentMetadata {
392 let storage_schema = StorageSchema::new([
393 Self::threshold_config_slot_schema(),
394 Self::approver_public_keys_slot_schema(),
395 Self::approver_auth_scheme_slot_schema(),
396 Self::executed_transactions_slot_schema(),
397 Self::procedure_thresholds_slot_schema(),
398 ])
399 .expect("storage schema should be valid");
400
401 AccountComponentMetadata::new(Self::NAME)
402 .with_description("Multisig authentication component using hybrid signature schemes")
403 .with_storage_schema(storage_schema)
404 }
405}
406
407impl From<AuthMultisig> for AccountComponent {
408 fn from(multisig: AuthMultisig) -> Self {
409 let mut storage_slots = Vec::with_capacity(5);
410
411 // Threshold config slot (value: [threshold, num_approvers, 0, 0])
412 let num_approvers = multisig.config.approvers().len() as u32;
413 storage_slots.push(StorageSlot::with_value(
414 AuthMultisig::threshold_config_slot().clone(),
415 Word::from([multisig.config.default_threshold(), num_approvers, 0, 0]),
416 ));
417
418 // Approver public keys slot (map)
419 let map_entries = multisig.config.approvers().iter().enumerate().map(|(i, approver)| {
420 (StorageMapKey::from_index(i as u32), Word::from(approver.pub_key()))
421 });
422
423 // Safe to unwrap because we know that the map keys are unique.
424 storage_slots.push(StorageSlot::with_map(
425 AuthMultisig::approver_public_keys_slot().clone(),
426 StorageMap::with_entries(map_entries).unwrap(),
427 ));
428
429 // Approver scheme IDs slot (map): [index, 0, 0, 0] => [scheme_id, 0, 0, 0]
430 let scheme_id_entries =
431 multisig.config.approvers().iter().enumerate().map(|(i, approver)| {
432 (
433 StorageMapKey::from_index(i as u32),
434 Word::from([approver.auth_scheme() as u32, 0, 0, 0]),
435 )
436 });
437
438 storage_slots.push(StorageSlot::with_map(
439 AuthMultisig::approver_scheme_ids_slot().clone(),
440 StorageMap::with_entries(scheme_id_entries).unwrap(),
441 ));
442
443 // Executed transactions slot (map)
444 let executed_transactions = StorageMap::default();
445 storage_slots.push(StorageSlot::with_map(
446 AuthMultisig::executed_transactions_slot().clone(),
447 executed_transactions,
448 ));
449
450 // Procedure thresholds slot (map: PROC_ROOT -> threshold)
451 let proc_threshold_roots = StorageMap::with_entries(
452 multisig.config.proc_thresholds().iter().map(|(proc_root, threshold)| {
453 (StorageMapKey::from_raw(proc_root.as_word()), Word::from([*threshold, 0, 0, 0]))
454 }),
455 )
456 .unwrap();
457 storage_slots.push(StorageSlot::with_map(
458 AuthMultisig::procedure_thresholds_slot().clone(),
459 proc_threshold_roots,
460 ));
461
462 let metadata = AuthMultisig::component_metadata();
463
464 AccountComponent::new(AuthMultisig::code().clone(), storage_slots, metadata).expect(
465 "Multisig auth component should satisfy the requirements of a valid account component",
466 )
467 }
468}
469
470// MULTISIG AUTH ARGS
471// ================================================================================================
472
473/// The inputs the multisig authentication components receive through the transaction's auth args.
474///
475/// ```text
476/// AUTH_ARGS: [BLOCK_WORD, SALT, CONVERSION_INFO]
477/// ```
478///
479/// where `BLOCK_WORD` is `[bound_block_num, approval_expiration_block_num, 0, 0]`.
480#[derive(Debug, Clone, Copy, PartialEq, Eq)]
481pub struct MultisigAuthArgs {
482 bound_block_num: BlockNumber,
483 approval_expiration_block_num: Option<BlockNumber>,
484 salt: Word,
485 conversion_info: Option<FeeConversionInfo>,
486}
487
488impl MultisigAuthArgs {
489 /// Creates new multisig auth args binding the summary to the given block.
490 ///
491 /// The signers approve a transaction summary that commits to `bound_block_num`, so the party
492 /// executing the transaction must pass the same block number, no matter how far the chain has
493 /// advanced since. The block must be at or before the transaction's reference block and must
494 /// be tracked by the transaction's partial blockchain, since that is the only way the kernel
495 /// can read its commitment.
496 ///
497 /// The approval does not expire unless [`Self::with_approval_expiration_delta`] sets an
498 /// expiration.
499 ///
500 /// `salt` is bound by the transaction summary and is what makes otherwise identical
501 /// transactions distinguishable, which is what the replay protection of the multisig
502 /// components relies on. It should be chosen at random.
503 pub fn new(bound_block_num: BlockNumber, salt: Word) -> Self {
504 Self {
505 bound_block_num,
506 approval_expiration_block_num: None,
507 salt,
508 conversion_info: None,
509 }
510 }
511
512 /// Returns new multisig auth args whose approval expires `delta` blocks after the bound block.
513 ///
514 /// The transaction must be included by block `bound_block_num + delta`. The expiration is bound
515 /// by the transaction summary, so the party executing the transaction can neither shorten nor
516 /// extend it.
517 ///
518 /// # Errors
519 ///
520 /// Returns an error if `bound_block_num + delta` exceeds [`BlockNumber::MAX`].
521 pub fn with_approval_expiration_delta(
522 mut self,
523 delta: NonZeroU32,
524 ) -> Result<Self, AccountError> {
525 let expiration_block_num =
526 self.bound_block_num.as_u32().checked_add(delta.get()).ok_or_else(|| {
527 AccountError::other(
528 "approval expiration block number exceeds the maximum block number",
529 )
530 })?;
531
532 self.approval_expiration_block_num = Some(BlockNumber::from(expiration_block_num));
533 Ok(self)
534 }
535
536 /// Returns new multisig auth args carrying the conversion info the fee payment needs.
537 ///
538 /// Must be [`FeeConversionInfo::one_to_one`] built with the reference block's fee faucet.
539 /// Anything else, or no conversion info at all, aborts on chains that charge a non-zero
540 /// verification base fee.
541 #[must_use]
542 pub fn with_conversion_info(mut self, conversion_info: FeeConversionInfo) -> Self {
543 self.conversion_info = Some(conversion_info);
544 self
545 }
546
547 // PUBLIC ACCESSORS
548 // --------------------------------------------------------------------------------------------
549
550 /// Returns the number of the block the transaction summary binds.
551 pub fn bound_block_num(&self) -> BlockNumber {
552 self.bound_block_num
553 }
554
555 /// Returns the first reference block at which the approvers' signatures are no longer valid,
556 /// or `None` if the approval does not expire.
557 pub fn approval_expiration_block_num(&self) -> Option<BlockNumber> {
558 self.approval_expiration_block_num
559 }
560
561 /// Returns the salt bound by the transaction summary.
562 pub fn salt(&self) -> Word {
563 self.salt
564 }
565
566 /// Returns the fee conversion info, or `None` if none was committed - in which case the fee
567 /// payment aborts on fee-charging chains.
568 pub fn conversion_info(&self) -> Option<FeeConversionInfo> {
569 self.conversion_info
570 }
571}
572
573impl SequentialCommit for MultisigAuthArgs {
574 type Commitment = Word;
575
576 fn to_elements(&self) -> Vec<Felt> {
577 let conversion_info = self.conversion_info.map_or(EMPTY_WORD, |info| info.to_word());
578 let approval_expiration = self.approval_expiration_block_num.map_or(Felt::ZERO, Felt::from);
579
580 let mut elements = Vec::with_capacity(3 * WORD_SIZE);
581 elements.extend([Felt::from(self.bound_block_num), approval_expiration, ZERO, ZERO]);
582 elements.extend(self.salt.iter());
583 elements.extend(conversion_info.iter());
584 elements
585 }
586}
587
588// TESTS
589// ================================================================================================
590
591#[cfg(test)]
592mod tests {
593 use alloc::string::ToString;
594
595 use miden_protocol::account::auth::AuthSecretKey;
596 use miden_protocol::account::{AccountBuilder, auth};
597
598 use super::*;
599 use crate::account::wallets::BasicWallet;
600
601 /// Test multisig component setup with various configurations
602 #[test]
603 fn test_multisig_component_setup() {
604 // Create test secret keys
605 let sec_key_1 = AuthSecretKey::new_falcon512_poseidon2();
606 let sec_key_2 = AuthSecretKey::new_falcon512_poseidon2();
607 let sec_key_3 = AuthSecretKey::new_falcon512_poseidon2();
608
609 // Create approvers list for multisig config
610 let approvers = vec![
611 Approver::new(sec_key_1.public_key().to_commitment(), sec_key_1.auth_scheme()),
612 Approver::new(sec_key_2.public_key().to_commitment(), sec_key_2.auth_scheme()),
613 Approver::new(sec_key_3.public_key().to_commitment(), sec_key_3.auth_scheme()),
614 ];
615
616 let threshold = 2u32;
617
618 // Create multisig component
619 let approver_set =
620 ApproverSet::new(approvers.clone(), threshold).expect("invalid approver set");
621 let multisig_component = AuthMultisig::new(AuthMultisigConfig::new(approver_set))
622 .expect("multisig component creation failed");
623
624 // Build account with multisig component
625 let account = AccountBuilder::new([0; 32])
626 .with_component(multisig_component)
627 .with_component(BasicWallet)
628 .build()
629 .expect("account building failed");
630
631 // Verify config slot: [threshold, num_approvers, 0, 0]
632 let config_slot = account
633 .storage()
634 .get_item(AuthMultisig::threshold_config_slot())
635 .expect("config storage slot access failed");
636 assert_eq!(config_slot, Word::from([threshold, approvers.len() as u32, 0, 0]));
637
638 // Verify approver pub keys slot
639 for (i, approver) in approvers.iter().enumerate() {
640 let stored_pub_key = account
641 .storage()
642 .get_map_item(
643 AuthMultisig::approver_public_keys_slot(),
644 StorageMapKey::from_index(i as u32),
645 )
646 .expect("approver public key storage map access failed");
647 assert_eq!(stored_pub_key, Word::from(approver.pub_key()));
648 }
649
650 // Verify approver scheme IDs slot
651 for (i, approver) in approvers.iter().enumerate() {
652 let stored_scheme_id = account
653 .storage()
654 .get_map_item(
655 AuthMultisig::approver_scheme_ids_slot(),
656 StorageMapKey::from_index(i as u32),
657 )
658 .expect("approver scheme ID storage map access failed");
659 assert_eq!(stored_scheme_id, Word::from([approver.auth_scheme() as u32, 0, 0, 0]));
660 }
661 }
662
663 /// Test multisig component with minimum threshold (1 of 1)
664 #[test]
665 fn test_multisig_component_minimum_threshold() {
666 let pub_key = AuthSecretKey::new_ecdsa_k256_keccak().public_key().to_commitment();
667 let approvers = vec![Approver::new(pub_key, auth::AuthScheme::EcdsaK256Keccak)];
668 let threshold = 1u32;
669
670 let approver_set =
671 ApproverSet::new(approvers.clone(), threshold).expect("invalid approver set");
672 let multisig_component = AuthMultisig::new(AuthMultisigConfig::new(approver_set))
673 .expect("multisig component creation failed");
674
675 let account = AccountBuilder::new([0; 32])
676 .with_component(multisig_component)
677 .with_component(BasicWallet)
678 .build()
679 .expect("account building failed");
680
681 // Verify storage layout
682 let config_slot = account
683 .storage()
684 .get_item(AuthMultisig::threshold_config_slot())
685 .expect("config storage slot access failed");
686 assert_eq!(config_slot, Word::from([threshold, approvers.len() as u32, 0, 0]));
687
688 let stored_pub_key = account
689 .storage()
690 .get_map_item(AuthMultisig::approver_public_keys_slot(), StorageMapKey::from_index(0))
691 .expect("approver pub keys storage map access failed");
692 assert_eq!(stored_pub_key, Word::from(pub_key));
693
694 let stored_scheme_id = account
695 .storage()
696 .get_map_item(AuthMultisig::approver_scheme_ids_slot(), StorageMapKey::from_index(0))
697 .expect("approver scheme IDs storage map access failed");
698 assert_eq!(
699 stored_scheme_id,
700 Word::from([auth::AuthScheme::EcdsaK256Keccak as u32, 0, 0, 0])
701 );
702 }
703
704 /// Test that a per-procedure threshold exceeding the number of approvers is rejected.
705 #[test]
706 fn test_proc_threshold_too_high() {
707 let pub_key = AuthSecretKey::new_ecdsa_k256_keccak().public_key().to_commitment();
708 let approvers = vec![Approver::new(pub_key, auth::AuthScheme::EcdsaK256Keccak)];
709 let approver_set = ApproverSet::new(approvers, 1).expect("invalid approver set");
710
711 let result = AuthMultisigConfig::new(approver_set)
712 .with_proc_thresholds(vec![(BasicWallet::receive_asset_root(), 2)]);
713 assert!(
714 result
715 .unwrap_err()
716 .to_string()
717 .contains("procedure threshold cannot be greater than number of approvers")
718 );
719 }
720
721 /// Test that an override exceeding the threshold guarding `set_procedure_threshold` (here the
722 /// default, since it has no override of its own) is rejected by `AuthMultisig::new`, because a
723 /// smaller quorum could lower it.
724 #[test]
725 fn test_proc_threshold_above_set_procedure_threshold_rejected() {
726 let approvers = vec![
727 Approver::new(
728 AuthSecretKey::new_ecdsa_k256_keccak().public_key().to_commitment(),
729 auth::AuthScheme::EcdsaK256Keccak,
730 ),
731 Approver::new(
732 AuthSecretKey::new_ecdsa_k256_keccak().public_key().to_commitment(),
733 auth::AuthScheme::EcdsaK256Keccak,
734 ),
735 Approver::new(
736 AuthSecretKey::new_ecdsa_k256_keccak().public_key().to_commitment(),
737 auth::AuthScheme::EcdsaK256Keccak,
738 ),
739 ];
740 let approver_set = ApproverSet::new(approvers, 2).expect("invalid approver set");
741
742 // The override (3) is within num_approvers, so `with_proc_thresholds` accepts it, but it
743 // exceeds the default threshold (2) that guards `set_procedure_threshold`.
744 let config = AuthMultisigConfig::new(approver_set)
745 .with_proc_thresholds(vec![(BasicWallet::receive_asset_root(), 3)])
746 .expect("an override within num_approvers is accepted by with_proc_thresholds");
747
748 let err = AuthMultisig::new(config).unwrap_err();
749 assert!(err.to_string().contains("exceeds the threshold"));
750 }
751}