use miden_processor::advice::AdviceInputs;
use miden_processor::crypto::random::RandomCoin;
use miden_protocol::account::auth::{AuthScheme, AuthSecretKey, PublicKey};
use miden_protocol::account::{
Account,
AccountBuilder,
AccountId,
AccountStorageMode,
AccountType,
};
use miden_protocol::asset::FungibleAsset;
use miden_protocol::note::NoteType;
use miden_protocol::testing::account_id::{
ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET,
ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_UPDATABLE_CODE,
};
use miden_protocol::transaction::RawOutputNote;
use miden_protocol::vm::AdviceMap;
use miden_protocol::{Felt, Hasher, Word};
use miden_standards::account::auth::AuthMultisig;
use miden_standards::account::components::multisig_library;
use miden_standards::account::interface::{AccountInterface, AccountInterfaceExt};
use miden_standards::account::wallets::BasicWallet;
use miden_standards::code_builder::CodeBuilder;
use miden_standards::errors::standards::{
ERR_PROC_THRESHOLD_EXCEEDS_NUM_APPROVERS,
ERR_TX_ALREADY_EXECUTED,
};
use miden_standards::note::P2idNote;
use miden_standards::testing::account_interface::get_public_keys_from_account;
use miden_testing::utils::create_spawn_note;
use miden_testing::{Auth, MockChainBuilder, assert_transaction_executor_error};
use miden_tx::TransactionExecutorError;
use miden_tx::auth::{BasicAuthenticator, SigningInputs, TransactionAuthenticator};
use rand::SeedableRng;
use rand_chacha::ChaCha20Rng;
use rstest::rstest;
type MultisigTestSetup =
(Vec<AuthSecretKey>, Vec<AuthScheme>, Vec<PublicKey>, Vec<BasicAuthenticator>);
fn setup_keys_and_authenticators_with_scheme(
num_approvers: usize,
threshold: usize,
auth_scheme: AuthScheme,
) -> anyhow::Result<MultisigTestSetup> {
let seed: [u8; 32] = rand::random();
let mut rng = ChaCha20Rng::from_seed(seed);
let mut secret_keys = Vec::new();
let mut auth_schemes = Vec::new();
let mut public_keys = Vec::new();
let mut authenticators = Vec::new();
for _ in 0..num_approvers {
let sec_key = match auth_scheme {
AuthScheme::EcdsaK256Keccak => AuthSecretKey::new_ecdsa_k256_keccak_with_rng(&mut rng),
AuthScheme::Falcon512Poseidon2 => {
AuthSecretKey::new_falcon512_poseidon2_with_rng(&mut rng)
},
_ => anyhow::bail!("unsupported auth scheme for this test: {auth_scheme:?}"),
};
let pub_key = sec_key.public_key();
secret_keys.push(sec_key);
auth_schemes.push(auth_scheme);
public_keys.push(pub_key);
}
for secret_key in secret_keys.iter().take(threshold) {
let authenticator = BasicAuthenticator::new(core::slice::from_ref(secret_key));
authenticators.push(authenticator);
}
Ok((secret_keys, auth_schemes, public_keys, authenticators))
}
fn create_multisig_account(
threshold: u32,
approvers: &[(PublicKey, AuthScheme)],
asset_amount: u64,
proc_threshold_map: Vec<(Word, u32)>,
) -> anyhow::Result<Account> {
let approvers = approvers
.iter()
.map(|(pub_key, auth_scheme)| (pub_key.to_commitment(), *auth_scheme))
.collect();
let multisig_account = AccountBuilder::new([0; 32])
.with_auth_component(Auth::Multisig { threshold, approvers, proc_threshold_map })
.with_component(BasicWallet)
.account_type(AccountType::RegularAccountUpdatableCode)
.storage_mode(AccountStorageMode::Public)
.with_assets(vec![FungibleAsset::mock(asset_amount)])
.build_existing()?;
Ok(multisig_account)
}
#[rstest]
#[case::ecdsa(AuthScheme::EcdsaK256Keccak)]
#[case::falcon(AuthScheme::Falcon512Poseidon2)]
#[tokio::test]
async fn test_multisig_2_of_2_with_note_creation(
#[case] auth_scheme: AuthScheme,
) -> anyhow::Result<()> {
let (_secret_keys, auth_schemes, public_keys, authenticators) =
setup_keys_and_authenticators_with_scheme(2, 2, auth_scheme)?;
let approvers = public_keys
.iter()
.zip(auth_schemes.iter())
.map(|(pk, scheme)| (pk.clone(), *scheme))
.collect::<Vec<_>>();
let multisig_starting_balance = 10u64;
let mut multisig_account =
create_multisig_account(2, &approvers, multisig_starting_balance, vec![])?;
let output_note_asset = FungibleAsset::mock(0);
let mut mock_chain_builder =
MockChainBuilder::with_accounts([multisig_account.clone()]).unwrap();
let output_note = mock_chain_builder.add_p2id_note(
multisig_account.id(),
ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_UPDATABLE_CODE.try_into().unwrap(),
&[output_note_asset],
NoteType::Public,
)?;
let input_note = mock_chain_builder.add_spawn_note([&output_note])?;
let mut mock_chain = mock_chain_builder.build().unwrap();
let salt = Word::from([Felt::new(1); 4]);
let tx_context_init = mock_chain
.build_tx_context(multisig_account.id(), &[input_note.id()], &[])?
.extend_expected_output_notes(vec![RawOutputNote::Full(output_note.clone())])
.auth_args(salt)
.build()?;
let tx_summary = match tx_context_init.execute().await.unwrap_err() {
TransactionExecutorError::Unauthorized(tx_effects) => tx_effects,
error => anyhow::bail!("expected abort with tx effects: {error}"),
};
let msg = tx_summary.as_ref().to_commitment();
let tx_summary = SigningInputs::TransactionSummary(tx_summary);
let sig_1 = authenticators[0]
.get_signature(public_keys[0].to_commitment(), &tx_summary)
.await?;
let sig_2 = authenticators[1]
.get_signature(public_keys[1].to_commitment(), &tx_summary)
.await?;
let tx_context_execute = mock_chain
.build_tx_context(multisig_account.id(), &[input_note.id()], &[])?
.extend_expected_output_notes(vec![RawOutputNote::Full(output_note)])
.add_signature(public_keys[0].to_commitment(), msg, sig_1)
.add_signature(public_keys[1].to_commitment(), msg, sig_2)
.auth_args(salt)
.build()?
.execute()
.await?;
multisig_account.apply_delta(tx_context_execute.account_delta())?;
mock_chain.add_pending_executed_transaction(&tx_context_execute)?;
mock_chain.prove_next_block()?;
assert_eq!(
multisig_account
.vault()
.get_balance(AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET)?)?,
multisig_starting_balance - output_note_asset.unwrap_fungible().amount()
);
Ok(())
}
#[rstest]
#[case::ecdsa(AuthScheme::EcdsaK256Keccak)]
#[case::falcon(AuthScheme::Falcon512Poseidon2)]
#[tokio::test]
async fn test_multisig_2_of_4_all_signer_combinations(
#[case] auth_scheme: AuthScheme,
) -> anyhow::Result<()> {
let (_secret_keys, auth_schemes, public_keys, authenticators) =
setup_keys_and_authenticators_with_scheme(4, 4, auth_scheme)?;
let approvers = public_keys
.iter()
.zip(auth_schemes.iter())
.map(|(pk, scheme)| (pk.clone(), *scheme))
.collect::<Vec<_>>();
let multisig_account = create_multisig_account(2, &approvers, 10, vec![])?;
let mut mock_chain = MockChainBuilder::with_accounts([multisig_account.clone()])
.unwrap()
.build()
.unwrap();
let signer_combinations = [
(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3), ];
for (i, (signer1_idx, signer2_idx)) in signer_combinations.iter().enumerate() {
let salt = Word::from([Felt::new(10 + i as u64); 4]);
let tx_context_init = mock_chain
.build_tx_context(multisig_account.id(), &[], &[])?
.auth_args(salt)
.build()?;
let tx_summary = match tx_context_init.execute().await.unwrap_err() {
TransactionExecutorError::Unauthorized(tx_effects) => tx_effects,
error => anyhow::bail!("expected abort with tx effects: {error}"),
};
let msg = tx_summary.as_ref().to_commitment();
let tx_summary = SigningInputs::TransactionSummary(tx_summary);
let sig_1 = authenticators[*signer1_idx]
.get_signature(public_keys[*signer1_idx].to_commitment(), &tx_summary)
.await?;
let sig_2 = authenticators[*signer2_idx]
.get_signature(public_keys[*signer2_idx].to_commitment(), &tx_summary)
.await?;
let tx_context_execute = mock_chain
.build_tx_context(multisig_account.id(), &[], &[])?
.auth_args(salt)
.add_signature(public_keys[*signer1_idx].to_commitment(), msg, sig_1)
.add_signature(public_keys[*signer2_idx].to_commitment(), msg, sig_2)
.build()?;
let executed_tx = tx_context_execute.execute().await.unwrap_or_else(|_| {
panic!("Transaction should succeed with signers {signer1_idx} and {signer2_idx}")
});
mock_chain.add_pending_executed_transaction(&executed_tx)?;
mock_chain.prove_next_block()?;
}
Ok(())
}
#[rstest]
#[case::ecdsa(AuthScheme::EcdsaK256Keccak)]
#[case::falcon(AuthScheme::Falcon512Poseidon2)]
#[tokio::test]
async fn test_multisig_replay_protection(#[case] auth_scheme: AuthScheme) -> anyhow::Result<()> {
let (_secret_keys, auth_schemes, public_keys, authenticators) =
setup_keys_and_authenticators_with_scheme(3, 2, auth_scheme)?;
let approvers = public_keys
.iter()
.zip(auth_schemes.iter())
.map(|(pk, scheme)| (pk.clone(), *scheme))
.collect::<Vec<_>>();
let multisig_account = create_multisig_account(2, &approvers, 20, vec![])?;
let mut mock_chain = MockChainBuilder::with_accounts([multisig_account.clone()])
.unwrap()
.build()
.unwrap();
let salt = Word::from([Felt::new(3); 4]);
let tx_context_init = mock_chain
.build_tx_context(multisig_account.id(), &[], &[])?
.auth_args(salt)
.build()?;
let tx_summary = match tx_context_init.execute().await.unwrap_err() {
TransactionExecutorError::Unauthorized(tx_effects) => tx_effects,
error => panic!("expected abort with tx effects: {error:?}"),
};
let msg = tx_summary.as_ref().to_commitment();
let tx_summary = SigningInputs::TransactionSummary(tx_summary);
let sig_1 = authenticators[0]
.get_signature(public_keys[0].to_commitment(), &tx_summary)
.await?;
let sig_2 = authenticators[1]
.get_signature(public_keys[1].to_commitment(), &tx_summary)
.await?;
let tx_context_execute = mock_chain
.build_tx_context(multisig_account.id(), &[], &[])?
.add_signature(public_keys[0].to_commitment(), msg, sig_1.clone())
.add_signature(public_keys[1].to_commitment(), msg, sig_2.clone())
.auth_args(salt)
.build()?
.execute()
.await?;
mock_chain.add_pending_executed_transaction(&tx_context_execute)?;
mock_chain.prove_next_block()?;
let tx_context_replay = mock_chain
.build_tx_context(multisig_account.id(), &[], &[])?
.add_signature(public_keys[0].to_commitment(), msg, sig_1)
.add_signature(public_keys[1].to_commitment(), msg, sig_2)
.auth_args(salt)
.build()?;
let result = tx_context_replay.execute().await;
assert_transaction_executor_error!(result, ERR_TX_ALREADY_EXECUTED);
Ok(())
}
#[rstest]
#[case::ecdsa(AuthScheme::EcdsaK256Keccak)]
#[case::falcon(AuthScheme::Falcon512Poseidon2)]
#[tokio::test]
async fn test_multisig_update_signers(#[case] auth_scheme: AuthScheme) -> anyhow::Result<()> {
let (_secret_keys, auth_schemes, public_keys, authenticators) =
setup_keys_and_authenticators_with_scheme(2, 2, auth_scheme)?;
let approvers = public_keys
.iter()
.zip(auth_schemes.iter())
.map(|(pk, scheme)| (pk.clone(), *scheme))
.collect::<Vec<_>>();
let multisig_account = create_multisig_account(2, &approvers, 10, vec![])?;
let mut mock_chain_builder =
MockChainBuilder::with_accounts([multisig_account.clone()]).unwrap();
let output_note_asset = FungibleAsset::mock(0);
let output_note = mock_chain_builder.add_p2id_note(
multisig_account.id(),
ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_UPDATABLE_CODE.try_into().unwrap(),
&[output_note_asset],
NoteType::Public,
)?;
let mut mock_chain = mock_chain_builder.clone().build().unwrap();
let salt = Word::from([Felt::new(3); 4]);
let mut advice_map = AdviceMap::default();
let (_new_secret_keys, _new_auth_schemes, new_public_keys, _new_authenticators) =
setup_keys_and_authenticators_with_scheme(4, 4, auth_scheme)?;
let threshold = 3u64;
let num_of_approvers = 4u64;
let mut config_and_pubkeys_vector = Vec::new();
config_and_pubkeys_vector.extend_from_slice(&[
Felt::new(threshold),
Felt::new(num_of_approvers),
Felt::new(0),
Felt::new(0),
]);
for public_key in new_public_keys.iter().rev() {
let key_word: Word = public_key.to_commitment().into();
config_and_pubkeys_vector.extend_from_slice(key_word.as_elements());
config_and_pubkeys_vector.extend_from_slice(&[
Felt::new(auth_scheme as u64),
Felt::new(0),
Felt::new(0),
Felt::new(0),
]);
}
let multisig_config_hash = Hasher::hash_elements(&config_and_pubkeys_vector);
advice_map.insert(multisig_config_hash, config_and_pubkeys_vector);
let tx_script_code = "
begin
call.::miden::standards::components::auth::multisig::update_signers_and_threshold
end
";
let tx_script = CodeBuilder::default()
.with_dynamically_linked_library(multisig_library())?
.compile_tx_script(tx_script_code)?;
let advice_inputs = AdviceInputs {
map: advice_map.clone(),
..Default::default()
};
let tx_script_args: Word = multisig_config_hash;
let tx_context_init = mock_chain
.build_tx_context(multisig_account.id(), &[], &[])?
.tx_script(tx_script.clone())
.tx_script_args(tx_script_args)
.extend_advice_inputs(advice_inputs.clone())
.auth_args(salt)
.build()?;
let tx_summary = match tx_context_init.execute().await.unwrap_err() {
TransactionExecutorError::Unauthorized(tx_effects) => tx_effects,
error => panic!("expected abort with tx effects: {error:?}"),
};
let msg = tx_summary.as_ref().to_commitment();
let tx_summary = SigningInputs::TransactionSummary(tx_summary);
let sig_1 = authenticators[0]
.get_signature(public_keys[0].to_commitment(), &tx_summary)
.await?;
let sig_2 = authenticators[1]
.get_signature(public_keys[1].to_commitment(), &tx_summary)
.await?;
let update_approvers_tx = mock_chain
.build_tx_context(multisig_account.id(), &[], &[])?
.tx_script(tx_script)
.tx_script_args(multisig_config_hash)
.add_signature(public_keys[0].to_commitment(), msg, sig_1)
.add_signature(public_keys[1].to_commitment(), msg, sig_2)
.auth_args(salt)
.extend_advice_inputs(advice_inputs)
.build()?
.execute()
.await?;
assert_eq!(update_approvers_tx.account_delta().nonce_delta(), Felt::new(1));
mock_chain.add_pending_executed_transaction(&update_approvers_tx)?;
mock_chain.prove_next_block()?;
let mut updated_multisig_account = multisig_account.clone();
updated_multisig_account.apply_delta(update_approvers_tx.account_delta())?;
for (i, expected_key) in new_public_keys.iter().enumerate() {
let storage_key = [Felt::new(i as u64), Felt::new(0), Felt::new(0), Felt::new(0)].into();
let storage_item = updated_multisig_account
.storage()
.get_map_item(AuthMultisig::approver_public_keys_slot(), storage_key)
.unwrap();
let expected_word: Word = expected_key.to_commitment().into();
assert_eq!(storage_item, expected_word, "Public key {} doesn't match expected value", i);
}
let threshold_config_storage = updated_multisig_account
.storage()
.get_item(AuthMultisig::threshold_config_slot())
.unwrap();
assert_eq!(
threshold_config_storage[0],
Felt::new(threshold),
"Threshold was not updated correctly"
);
assert_eq!(
threshold_config_storage[1],
Felt::new(num_of_approvers),
"Num approvers was not updated correctly"
);
let extracted_pub_keys = get_public_keys_from_account(&updated_multisig_account);
assert_eq!(
extracted_pub_keys.len(),
4,
"get_public_keys_from_account should return 4 public keys after update"
);
for (i, expected_key) in new_public_keys.iter().enumerate() {
let expected_word: Word = expected_key.to_commitment().into();
let found_key = extracted_pub_keys.iter().find(|&key| *key == expected_word);
assert!(
found_key.is_some(),
"Public key {} not found in extracted keys: expected {:?}, got {:?}",
i,
expected_word,
extracted_pub_keys
);
}
let mut new_authenticators = Vec::new();
for secret_key in _new_secret_keys.iter().take(3) {
let authenticator = BasicAuthenticator::new(core::slice::from_ref(secret_key));
new_authenticators.push(authenticator);
}
let output_note_new = P2idNote::create(
updated_multisig_account.id(),
ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_UPDATABLE_CODE.try_into().unwrap(),
vec![output_note_asset],
NoteType::Public,
Default::default(),
&mut RandomCoin::new(Word::empty()),
)?;
let input_note_new = create_spawn_note([&output_note_new])?;
let salt_new = Word::from([Felt::new(4); 4]);
let mut new_mock_chain_builder =
MockChainBuilder::with_accounts([updated_multisig_account.clone()]).unwrap();
new_mock_chain_builder.add_output_note(RawOutputNote::Full(input_note_new.clone()));
let new_mock_chain = new_mock_chain_builder.build().unwrap();
let tx_context_init_new = new_mock_chain
.build_tx_context(updated_multisig_account.id(), &[input_note_new.id()], &[])?
.extend_expected_output_notes(vec![RawOutputNote::Full(output_note.clone())])
.auth_args(salt_new)
.build()?;
let tx_summary_new = match tx_context_init_new.execute().await.unwrap_err() {
TransactionExecutorError::Unauthorized(tx_effects) => tx_effects,
error => panic!("expected abort with tx effects: {error:?}"),
};
let msg_new = tx_summary_new.as_ref().to_commitment();
let tx_summary_new = SigningInputs::TransactionSummary(tx_summary_new);
let sig_1_new = new_authenticators[0]
.get_signature(new_public_keys[0].to_commitment(), &tx_summary_new)
.await?;
let sig_2_new = new_authenticators[1]
.get_signature(new_public_keys[1].to_commitment(), &tx_summary_new)
.await?;
let sig_3_new = new_authenticators[2]
.get_signature(new_public_keys[2].to_commitment(), &tx_summary_new)
.await?;
let tx_context_execute_new = new_mock_chain
.build_tx_context(updated_multisig_account.id(), &[input_note_new.id()], &[])?
.extend_expected_output_notes(vec![RawOutputNote::Full(output_note_new)])
.add_signature(new_public_keys[0].to_commitment(), msg_new, sig_1_new)
.add_signature(new_public_keys[1].to_commitment(), msg_new, sig_2_new)
.add_signature(new_public_keys[2].to_commitment(), msg_new, sig_3_new)
.auth_args(salt_new)
.build()?
.execute()
.await?;
assert_eq!(tx_context_execute_new.account_delta().nonce_delta(), Felt::new(1));
Ok(())
}
#[rstest]
#[case::ecdsa(AuthScheme::EcdsaK256Keccak)]
#[case::falcon(AuthScheme::Falcon512Poseidon2)]
#[tokio::test]
async fn test_multisig_update_signers_remove_owner(
#[case] auth_scheme: AuthScheme,
) -> anyhow::Result<()> {
let (_secret_keys, auth_schemes, public_keys, authenticators) =
setup_keys_and_authenticators_with_scheme(5, 5, auth_scheme)?;
let approvers = public_keys
.iter()
.zip(auth_schemes.iter())
.map(|(pk, scheme)| (pk.clone(), *scheme))
.collect::<Vec<_>>();
let multisig_account = create_multisig_account(4, &approvers, 10, vec![])?;
let mock_chain_builder = MockChainBuilder::with_accounts([multisig_account.clone()]).unwrap();
let mut mock_chain = mock_chain_builder.build().unwrap();
let new_public_keys = &public_keys[0..2];
let threshold = 1u64;
let num_of_approvers = 2u64;
let mut config_and_pubkeys_vector =
vec![Felt::new(threshold), Felt::new(num_of_approvers), Felt::new(0), Felt::new(0)];
for public_key in new_public_keys.iter().rev() {
let key_word: Word = public_key.to_commitment().into();
config_and_pubkeys_vector.extend_from_slice(key_word.as_elements());
config_and_pubkeys_vector.extend_from_slice(&[
Felt::new(auth_scheme as u64),
Felt::new(0),
Felt::new(0),
Felt::new(0),
]);
}
let multisig_config_hash = Hasher::hash_elements(&config_and_pubkeys_vector);
let mut advice_map = AdviceMap::default();
advice_map.insert(multisig_config_hash, config_and_pubkeys_vector);
let tx_script = CodeBuilder::default()
.with_dynamically_linked_library(multisig_library())?
.compile_tx_script("begin\n call.::miden::standards::components::auth::multisig::update_signers_and_threshold\nend")?;
let advice_inputs = AdviceInputs { map: advice_map, ..Default::default() };
let salt = Word::from([Felt::new(3); 4]);
let tx_context_init = mock_chain
.build_tx_context(multisig_account.id(), &[], &[])?
.tx_script(tx_script.clone())
.tx_script_args(multisig_config_hash)
.extend_advice_inputs(advice_inputs.clone())
.auth_args(salt)
.build()?;
let tx_summary = match tx_context_init.execute().await.unwrap_err() {
TransactionExecutorError::Unauthorized(tx_effects) => tx_effects,
error => panic!("expected abort with tx effects: {error:?}"),
};
let msg = tx_summary.as_ref().to_commitment();
let tx_summary = SigningInputs::TransactionSummary(tx_summary);
let sig_1 = authenticators[0]
.get_signature(public_keys[0].to_commitment(), &tx_summary)
.await?;
let sig_2 = authenticators[1]
.get_signature(public_keys[1].to_commitment(), &tx_summary)
.await?;
let sig_3 = authenticators[2]
.get_signature(public_keys[2].to_commitment(), &tx_summary)
.await?;
let sig_4 = authenticators[3]
.get_signature(public_keys[3].to_commitment(), &tx_summary)
.await?;
let update_approvers_tx = mock_chain
.build_tx_context(multisig_account.id(), &[], &[])?
.tx_script(tx_script)
.tx_script_args(multisig_config_hash)
.add_signature(public_keys[0].to_commitment(), msg, sig_1)
.add_signature(public_keys[1].to_commitment(), msg, sig_2)
.add_signature(public_keys[2].to_commitment(), msg, sig_3)
.add_signature(public_keys[3].to_commitment(), msg, sig_4)
.auth_args(salt)
.extend_advice_inputs(advice_inputs)
.build()?
.execute()
.await?;
assert_eq!(update_approvers_tx.account_delta().nonce_delta(), Felt::new(1));
mock_chain.add_pending_executed_transaction(&update_approvers_tx)?;
mock_chain.prove_next_block()?;
let mut updated_multisig_account = multisig_account.clone();
updated_multisig_account.apply_delta(update_approvers_tx.account_delta())?;
for (i, expected_key) in new_public_keys.iter().enumerate() {
let storage_key = [Felt::new(i as u64), Felt::new(0), Felt::new(0), Felt::new(0)].into();
let storage_item = updated_multisig_account
.storage()
.get_map_item(AuthMultisig::approver_public_keys_slot(), storage_key)
.unwrap();
let expected_word: Word = expected_key.to_commitment().into();
assert_eq!(storage_item, expected_word, "Public key {} doesn't match", i);
}
let threshold_config = updated_multisig_account
.storage()
.get_item(AuthMultisig::threshold_config_slot())
.unwrap();
assert_eq!(threshold_config[0], Felt::new(threshold), "Threshold not updated");
assert_eq!(threshold_config[1], Felt::new(num_of_approvers), "Num approvers not updated");
let extracted_pub_keys = get_public_keys_from_account(&updated_multisig_account);
assert_eq!(extracted_pub_keys.len(), 2, "Should have 2 public keys after update");
for expected_key in new_public_keys.iter() {
let expected_word: Word = expected_key.to_commitment().into();
assert!(
extracted_pub_keys.contains(&expected_word),
"Public key not found in extracted keys"
);
}
for removed_idx in 2..5 {
let removed_owner_key =
[Felt::new(removed_idx), Felt::new(0), Felt::new(0), Felt::new(0)].into();
let removed_owner_slot = updated_multisig_account
.storage()
.get_map_item(AuthMultisig::approver_public_keys_slot(), removed_owner_key)
.unwrap();
assert_eq!(
removed_owner_slot,
Word::empty(),
"Removed owner's slot at index {} should be empty",
removed_idx
);
}
let mut non_empty_count = 0;
for i in 0..5 {
let storage_key = [Felt::new(i as u64), Felt::new(0), Felt::new(0), Felt::new(0)].into();
let storage_item = updated_multisig_account
.storage()
.get_map_item(AuthMultisig::approver_public_keys_slot(), storage_key)
.unwrap();
if storage_item != Word::empty() {
non_empty_count += 1;
assert!(i < 2, "Found non-empty key at index {} which should be removed", i);
let expected_word: Word = new_public_keys.get(i).unwrap().to_commitment().into();
assert_eq!(storage_item, expected_word, "Key at index {} doesn't match", i);
}
}
assert_eq!(
non_empty_count, 2,
"Should have exactly 2 non-empty keys after removing 3 owners"
);
Ok(())
}
#[rstest]
#[case::ecdsa(AuthScheme::EcdsaK256Keccak)]
#[case::falcon(AuthScheme::Falcon512Poseidon2)]
#[tokio::test]
async fn test_multisig_update_signers_rejects_unreachable_proc_thresholds(
#[case] auth_scheme: AuthScheme,
) -> anyhow::Result<()> {
let (_secret_keys, auth_schemes, public_keys, _authenticators) =
setup_keys_and_authenticators_with_scheme(3, 2, auth_scheme)?;
let approvers = public_keys
.iter()
.zip(auth_schemes.iter())
.map(|(pk, scheme)| (pk.clone(), *scheme))
.collect::<Vec<_>>();
let multisig_account =
create_multisig_account(2, &approvers, 10, vec![(BasicWallet::receive_asset_digest(), 3)])?;
let mock_chain = MockChainBuilder::with_accounts([multisig_account.clone()])
.unwrap()
.build()
.unwrap();
let new_public_keys = &public_keys[0..2];
let threshold = 2u64;
let num_of_approvers = 2u64;
let mut config_and_pubkeys_vector =
vec![Felt::new(threshold), Felt::new(num_of_approvers), Felt::new(0), Felt::new(0)];
for public_key in new_public_keys.iter().rev() {
let key_word: Word = public_key.to_commitment().into();
config_and_pubkeys_vector.extend_from_slice(key_word.as_elements());
config_and_pubkeys_vector.extend_from_slice(&[
Felt::new(auth_scheme as u64),
Felt::new(0),
Felt::new(0),
Felt::new(0),
]);
}
let multisig_config_hash = Hasher::hash_elements(&config_and_pubkeys_vector);
let mut advice_map = AdviceMap::default();
advice_map.insert(multisig_config_hash, config_and_pubkeys_vector);
let tx_script = CodeBuilder::default()
.with_dynamically_linked_library(multisig_library())?
.compile_tx_script("begin\n call.::miden::standards::components::auth::multisig::update_signers_and_threshold\nend")?;
let advice_inputs = AdviceInputs { map: advice_map, ..Default::default() };
let salt = Word::from([Felt::new(8); 4]);
let result = mock_chain
.build_tx_context(multisig_account.id(), &[], &[])?
.tx_script(tx_script)
.tx_script_args(multisig_config_hash)
.extend_advice_inputs(advice_inputs)
.auth_args(salt)
.build()?
.execute()
.await;
assert_transaction_executor_error!(result, ERR_PROC_THRESHOLD_EXCEEDS_NUM_APPROVERS);
Ok(())
}
#[rstest]
#[case::ecdsa(AuthScheme::EcdsaK256Keccak)]
#[case::falcon(AuthScheme::Falcon512Poseidon2)]
#[tokio::test]
async fn test_multisig_new_approvers_cannot_sign_before_update(
#[case] auth_scheme: AuthScheme,
) -> anyhow::Result<()> {
let (_secret_keys, auth_schemes, public_keys, _authenticators) =
setup_keys_and_authenticators_with_scheme(2, 2, auth_scheme)?;
let approvers = public_keys
.iter()
.zip(auth_schemes.iter())
.map(|(pk, scheme)| (pk.clone(), *scheme))
.collect::<Vec<_>>();
let multisig_account = create_multisig_account(2, &approvers, 10, vec![])?;
let mock_chain = MockChainBuilder::with_accounts([multisig_account.clone()])
.unwrap()
.build()
.unwrap();
let salt = Word::from([Felt::new(5); 4]);
let mut advice_map = AdviceMap::default();
let (_new_secret_keys, _new_auth_schemes, new_public_keys, new_authenticators) =
setup_keys_and_authenticators_with_scheme(4, 4, auth_scheme)?;
let threshold = 3u64;
let num_of_approvers = 4u64;
let mut config_and_pubkeys_vector = Vec::new();
config_and_pubkeys_vector.extend_from_slice(&[
Felt::new(threshold),
Felt::new(num_of_approvers),
Felt::new(0),
Felt::new(0),
]);
for public_key in new_public_keys.iter().rev() {
let key_word: Word = public_key.to_commitment().into();
config_and_pubkeys_vector.extend_from_slice(key_word.as_elements());
config_and_pubkeys_vector.extend_from_slice(&[
Felt::new(auth_scheme as u64),
Felt::new(0),
Felt::new(0),
Felt::new(0),
]);
}
let multisig_config_hash = Hasher::hash_elements(&config_and_pubkeys_vector);
advice_map.insert(multisig_config_hash, config_and_pubkeys_vector);
let tx_script_code = "
begin
call.::miden::standards::components::auth::multisig::update_signers_and_threshold
end
";
let tx_script = CodeBuilder::default()
.with_dynamically_linked_library(multisig_library())?
.compile_tx_script(tx_script_code)?;
let advice_inputs = AdviceInputs {
map: advice_map.clone(),
..Default::default()
};
let tx_script_args: Word = multisig_config_hash;
let tx_context_init = mock_chain
.build_tx_context(multisig_account.id(), &[], &[])?
.tx_script(tx_script.clone())
.tx_script_args(tx_script_args)
.extend_advice_inputs(advice_inputs.clone())
.auth_args(salt)
.build()?;
let tx_summary = match tx_context_init.execute().await.unwrap_err() {
TransactionExecutorError::Unauthorized(tx_effects) => tx_effects,
error => panic!("expected abort with tx effects: {error:?}"),
};
let msg = tx_summary.as_ref().to_commitment();
let tx_summary_signing = SigningInputs::TransactionSummary(tx_summary.clone());
let new_sig_1 = new_authenticators[0]
.get_signature(new_public_keys[0].to_commitment(), &tx_summary_signing)
.await?;
let new_sig_2 = new_authenticators[1]
.get_signature(new_public_keys[1].to_commitment(), &tx_summary_signing)
.await?;
let tx_context_with_new_sigs = mock_chain
.build_tx_context(multisig_account.id(), &[], &[])?
.tx_script(tx_script.clone())
.tx_script_args(multisig_config_hash)
.add_signature(new_public_keys[0].to_commitment(), msg, new_sig_1)
.add_signature(new_public_keys[1].to_commitment(), msg, new_sig_2)
.auth_args(salt)
.extend_advice_inputs(advice_inputs.clone())
.build()?;
let result = tx_context_with_new_sigs.execute().await;
assert!(
result.is_err(),
"Transaction should fail when signed by unauthorized new approvers"
);
Ok(())
}
#[rstest]
#[case::ecdsa(AuthScheme::EcdsaK256Keccak)]
#[case::falcon(AuthScheme::Falcon512Poseidon2)]
#[tokio::test]
async fn test_multisig_proc_threshold_overrides(
#[case] auth_scheme: AuthScheme,
) -> anyhow::Result<()> {
let (_secret_keys, auth_schemes, public_keys, authenticators) =
setup_keys_and_authenticators_with_scheme(2, 2, auth_scheme)?;
let proc_threshold_map = vec![(BasicWallet::receive_asset_digest(), 1)];
let approvers = public_keys
.iter()
.zip(auth_schemes.iter())
.map(|(pk, scheme)| (pk.clone(), *scheme))
.collect::<Vec<_>>();
let multisig_starting_balance = 10u64;
let mut multisig_account =
create_multisig_account(2, &approvers, multisig_starting_balance, proc_threshold_map)?;
let mut mock_chain_builder =
MockChainBuilder::with_accounts([multisig_account.clone()]).unwrap();
let note = mock_chain_builder.add_p2id_note(
multisig_account.id(),
multisig_account.id(),
&[FungibleAsset::mock(1)],
NoteType::Public,
)?;
let mut mock_chain = mock_chain_builder.build()?;
let salt = Word::from([Felt::new(1); 4]);
let tx_context = mock_chain
.build_tx_context(multisig_account.id(), &[note.id()], &[])?
.auth_args(salt)
.build()?;
let tx_summary = match tx_context.execute().await.unwrap_err() {
TransactionExecutorError::Unauthorized(tx_summary) => tx_summary,
error => panic!("expected abort with tx summary: {error:?}"),
};
let msg = tx_summary.as_ref().to_commitment();
let tx_summary_signing = SigningInputs::TransactionSummary(tx_summary.clone());
let sig = authenticators[0]
.get_signature(public_keys[0].to_commitment(), &tx_summary_signing)
.await?;
let tx_result = mock_chain
.build_tx_context(multisig_account.id(), &[note.id()], &[])?
.add_signature(public_keys[0].to_commitment(), msg, sig)
.auth_args(salt)
.build()?
.execute()
.await;
assert!(tx_result.is_ok(), "Note consumption with 1 signature should succeed");
multisig_account.apply_delta(tx_result.as_ref().unwrap().account_delta())?;
mock_chain.add_pending_executed_transaction(&tx_result.unwrap())?;
mock_chain.prove_next_block()?;
let salt2 = Word::from([Felt::new(2); 4]);
let output_note = P2idNote::create(
multisig_account.id(),
ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_UPDATABLE_CODE.try_into().unwrap(),
vec![FungibleAsset::mock(5)],
NoteType::Public,
Default::default(),
&mut RandomCoin::new(Word::from([Felt::new(42); 4])),
)?;
let multisig_account_interface = AccountInterface::from_account(&multisig_account);
let send_note_transaction_script =
multisig_account_interface.build_send_notes_script(&[output_note.clone().into()], None)?;
let tx_context_init = mock_chain
.build_tx_context(multisig_account.id(), &[], &[])?
.extend_expected_output_notes(vec![RawOutputNote::Full(output_note.clone())])
.tx_script(send_note_transaction_script.clone())
.auth_args(salt2)
.build()?;
let tx_summary2 = match tx_context_init.execute().await.unwrap_err() {
TransactionExecutorError::Unauthorized(tx_effects) => tx_effects,
error => panic!("expected abort with tx effects: {error:?}"),
};
let msg2 = tx_summary2.as_ref().to_commitment();
let tx_summary2_signing = SigningInputs::TransactionSummary(tx_summary2.clone());
let sig_1 = authenticators[0]
.get_signature(public_keys[0].to_commitment(), &tx_summary2_signing)
.await?;
let tx_context_one_sig = mock_chain
.build_tx_context(multisig_account.id(), &[], &[])?
.extend_expected_output_notes(vec![RawOutputNote::Full(output_note.clone())])
.add_signature(public_keys[0].to_commitment(), msg2, sig_1)
.tx_script(send_note_transaction_script.clone())
.auth_args(salt2)
.build()?;
let result = tx_context_one_sig.execute().await;
match result {
Err(TransactionExecutorError::Unauthorized(_)) => {
},
_ => panic!(
"Transaction should fail with Unauthorized error when only 1 signature provided for note sending"
),
}
let sig_1 = authenticators[0]
.get_signature(public_keys[0].to_commitment(), &tx_summary2_signing)
.await?;
let sig_2 = authenticators[1]
.get_signature(public_keys[1].to_commitment(), &tx_summary2_signing)
.await?;
let result = mock_chain
.build_tx_context(multisig_account.id(), &[], &[])?
.extend_expected_output_notes(vec![RawOutputNote::Full(output_note)])
.add_signature(public_keys[0].to_commitment(), msg2, sig_1)
.add_signature(public_keys[1].to_commitment(), msg2, sig_2)
.auth_args(salt2)
.tx_script(send_note_transaction_script)
.build()?
.execute()
.await;
assert!(result.is_ok(), "Transaction should succeed with 2 signatures for note sending");
multisig_account.apply_delta(result.as_ref().unwrap().account_delta())?;
mock_chain.add_pending_executed_transaction(&result.unwrap())?;
mock_chain.prove_next_block()?;
assert_eq!(multisig_account.vault().get_balance(FungibleAsset::mock_issuer())?, 6);
Ok(())
}
#[rstest]
#[case::ecdsa(AuthScheme::EcdsaK256Keccak)]
#[case::falcon(AuthScheme::Falcon512Poseidon2)]
#[tokio::test]
async fn test_multisig_set_procedure_threshold(
#[case] auth_scheme: AuthScheme,
) -> anyhow::Result<()> {
let (_secret_keys, auth_schemes, public_keys, authenticators) =
setup_keys_and_authenticators_with_scheme(2, 2, auth_scheme)?;
let approvers = public_keys
.iter()
.zip(auth_schemes.iter())
.map(|(pk, scheme)| (pk.clone(), *scheme))
.collect::<Vec<_>>();
let mut multisig_account = create_multisig_account(2, &approvers, 10, vec![])?;
let mut mock_chain_builder =
MockChainBuilder::with_accounts([multisig_account.clone()]).unwrap();
let one_sig_note = mock_chain_builder.add_p2id_note(
multisig_account.id(),
multisig_account.id(),
&[FungibleAsset::mock(1)],
NoteType::Public,
)?;
let clear_check_note = mock_chain_builder.add_p2id_note(
multisig_account.id(),
multisig_account.id(),
&[FungibleAsset::mock(1)],
NoteType::Public,
)?;
let mut mock_chain = mock_chain_builder.build().unwrap();
let proc_root = BasicWallet::receive_asset_digest();
let set_script_code = format!(
r#"
begin
push.{proc_root}
push.1
call.::miden::standards::components::auth::multisig::set_procedure_threshold
dropw
drop
end
"#
);
let set_script = CodeBuilder::default()
.with_dynamically_linked_library(multisig_library())?
.compile_tx_script(set_script_code)?;
let set_salt = Word::from([Felt::new(50); 4]);
let set_init = mock_chain
.build_tx_context(multisig_account.id(), &[], &[])?
.tx_script(set_script.clone())
.auth_args(set_salt)
.build()?;
let set_summary = match set_init.execute().await.unwrap_err() {
TransactionExecutorError::Unauthorized(tx_effects) => tx_effects,
error => panic!("expected abort with tx effects: {error:?}"),
};
let set_msg = set_summary.as_ref().to_commitment();
let set_summary = SigningInputs::TransactionSummary(set_summary);
let set_sig_1 = authenticators[0]
.get_signature(public_keys[0].to_commitment(), &set_summary)
.await?;
let set_sig_2 = authenticators[1]
.get_signature(public_keys[1].to_commitment(), &set_summary)
.await?;
let set_tx = mock_chain
.build_tx_context(multisig_account.id(), &[], &[])?
.tx_script(set_script)
.add_signature(public_keys[0].to_commitment(), set_msg, set_sig_1)
.add_signature(public_keys[1].to_commitment(), set_msg, set_sig_2)
.auth_args(set_salt)
.build()?
.execute()
.await?;
multisig_account.apply_delta(set_tx.account_delta())?;
mock_chain.add_pending_executed_transaction(&set_tx)?;
mock_chain.prove_next_block()?;
let one_sig_salt = Word::from([Felt::new(51); 4]);
let one_sig_init = mock_chain
.build_tx_context(multisig_account.id(), &[one_sig_note.id()], &[])?
.auth_args(one_sig_salt)
.build()?;
let one_sig_summary = match one_sig_init.execute().await.unwrap_err() {
TransactionExecutorError::Unauthorized(tx_effects) => tx_effects,
error => panic!("expected abort with tx effects: {error:?}"),
};
let one_sig_msg = one_sig_summary.as_ref().to_commitment();
let one_sig_summary = SigningInputs::TransactionSummary(one_sig_summary);
let one_sig = authenticators[0]
.get_signature(public_keys[0].to_commitment(), &one_sig_summary)
.await?;
let one_sig_tx = mock_chain
.build_tx_context(multisig_account.id(), &[one_sig_note.id()], &[])?
.add_signature(public_keys[0].to_commitment(), one_sig_msg, one_sig)
.auth_args(one_sig_salt)
.build()?
.execute()
.await
.expect("override=1 should allow receive_asset with one signature");
multisig_account.apply_delta(one_sig_tx.account_delta())?;
mock_chain.add_pending_executed_transaction(&one_sig_tx)?;
mock_chain.prove_next_block()?;
let clear_script_code = format!(
r#"
begin
push.{proc_root}
push.0
call.::miden::standards::components::auth::multisig::set_procedure_threshold
dropw
drop
end
"#
);
let clear_script = CodeBuilder::default()
.with_dynamically_linked_library(multisig_library())?
.compile_tx_script(clear_script_code)?;
let clear_salt = Word::from([Felt::new(52); 4]);
let clear_init = mock_chain
.build_tx_context(multisig_account.id(), &[], &[])?
.tx_script(clear_script.clone())
.auth_args(clear_salt)
.build()?;
let clear_summary = match clear_init.execute().await.unwrap_err() {
TransactionExecutorError::Unauthorized(tx_effects) => tx_effects,
error => panic!("expected abort with tx effects: {error:?}"),
};
let clear_msg = clear_summary.as_ref().to_commitment();
let clear_summary = SigningInputs::TransactionSummary(clear_summary);
let clear_sig_1 = authenticators[0]
.get_signature(public_keys[0].to_commitment(), &clear_summary)
.await?;
let clear_sig_2 = authenticators[1]
.get_signature(public_keys[1].to_commitment(), &clear_summary)
.await?;
let clear_tx = mock_chain
.build_tx_context(multisig_account.id(), &[], &[])?
.tx_script(clear_script)
.add_signature(public_keys[0].to_commitment(), clear_msg, clear_sig_1)
.add_signature(public_keys[1].to_commitment(), clear_msg, clear_sig_2)
.auth_args(clear_salt)
.build()?
.execute()
.await?;
multisig_account.apply_delta(clear_tx.account_delta())?;
mock_chain.add_pending_executed_transaction(&clear_tx)?;
mock_chain.prove_next_block()?;
let clear_check_salt = Word::from([Felt::new(53); 4]);
let clear_check_init = mock_chain
.build_tx_context(multisig_account.id(), &[clear_check_note.id()], &[])?
.auth_args(clear_check_salt)
.build()?;
let clear_check_summary = match clear_check_init.execute().await.unwrap_err() {
TransactionExecutorError::Unauthorized(tx_effects) => tx_effects,
error => panic!("expected abort with tx effects: {error:?}"),
};
let clear_check_msg = clear_check_summary.as_ref().to_commitment();
let clear_check_summary = SigningInputs::TransactionSummary(clear_check_summary);
let clear_check_sig = authenticators[0]
.get_signature(public_keys[0].to_commitment(), &clear_check_summary)
.await?;
let clear_check_result = mock_chain
.build_tx_context(multisig_account.id(), &[clear_check_note.id()], &[])?
.add_signature(public_keys[0].to_commitment(), clear_check_msg, clear_check_sig)
.auth_args(clear_check_salt)
.build()?
.execute()
.await;
assert!(
matches!(clear_check_result, Err(TransactionExecutorError::Unauthorized(_))),
"override cleared via threshold=0 should restore default threshold requirements"
);
Ok(())
}
#[rstest]
#[case::ecdsa(AuthScheme::EcdsaK256Keccak)]
#[case::falcon(AuthScheme::Falcon512Poseidon2)]
#[tokio::test]
async fn test_multisig_set_procedure_threshold_rejects_exceeding_approvers(
#[case] auth_scheme: AuthScheme,
) -> anyhow::Result<()> {
let (_secret_keys, auth_schemes, public_keys, _authenticators) =
setup_keys_and_authenticators_with_scheme(2, 2, auth_scheme)?;
let approvers = public_keys
.iter()
.zip(auth_schemes.iter())
.map(|(pk, scheme)| (pk.clone(), *scheme))
.collect::<Vec<_>>();
let multisig_account = create_multisig_account(2, &approvers, 10, vec![])?;
let proc_root = BasicWallet::receive_asset_digest();
let script_code = format!(
r#"
begin
push.{proc_root}
push.3
call.::miden::standards::components::auth::multisig::set_procedure_threshold
end
"#
);
let script = CodeBuilder::default()
.with_dynamically_linked_library(multisig_library())?
.compile_tx_script(script_code)?;
let mock_chain = MockChainBuilder::with_accounts([multisig_account.clone()])
.unwrap()
.build()
.unwrap();
let salt = Word::from([Felt::new(54); 4]);
let tx_context_init = mock_chain
.build_tx_context(multisig_account.id(), &[], &[])?
.tx_script(script.clone())
.auth_args(salt)
.build()?;
let result = tx_context_init.execute().await;
assert_transaction_executor_error!(result, ERR_PROC_THRESHOLD_EXCEEDS_NUM_APPROVERS);
Ok(())
}