use std::num::NonZeroU32;
use std::sync::Arc;
use guardian_client::GuardianClient;
use guardian_shared::ToJson;
use miden_client::rpc::NodeRpcClient;
use miden_protocol::Word;
use miden_protocol::account::AccountId;
use miden_protocol::note::{NoteId, NoteType};
use miden_standards::account::auth::{ApproverSet, MultisigAuthArgs};
use crate::MidenSdkClient;
use crate::account::MultisigAccount;
use crate::error::{MultisigError, Result};
use crate::execution::build_transfer_asset;
use crate::guardian_endpoint::verify_endpoint_commitment;
use crate::keystore::{KeyManager, ensure_hex_prefix};
use crate::payload::ProposalPayload;
use crate::procedures::ProcedureName;
use crate::proposal::{P2ideHeights, Proposal, ProposalMetadata, TransactionType};
use crate::utils::hex_body_eq;
use super::{
build_p2id_transaction_request, build_update_guardian_transaction_request,
build_update_procedure_threshold_transaction_request, build_update_signers_transaction_request,
chain_anchor_to_base64, execute_for_summary, generate_salt, proposer_auth_args, word_to_hex,
};
pub struct ProposalBuilder {
transaction_type: TransactionType,
approval_expiration_delta: Option<NonZeroU32>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct ProposalOptions {
pub approval_expiration_delta: Option<NonZeroU32>,
}
fn ensure_admissible_signer_set(signers: &[Word], guardian_commitment: Word) -> Result<()> {
let max_approvers = usize::from(ApproverSet::MAX_APPROVERS);
if signers.len() > max_approvers {
return Err(MultisigError::InvalidConfig(format!(
"too many signers ({}): a multisig account holds at most {max_approvers}",
signers.len()
)));
}
let mut seen = std::collections::HashSet::with_capacity(signers.len());
if let Some(duplicate) = signers.iter().find(|signer| !seen.insert(**signer)) {
return Err(MultisigError::InvalidConfig(format!(
"duplicate signer commitment: {}",
word_to_hex(duplicate)
)));
}
if signers.contains(&guardian_commitment) {
return Err(MultisigError::InvalidConfig(
"GUARDIAN commitment must be different from all signer commitments".to_string(),
));
}
Ok(())
}
fn validate_threshold_change(
current_threshold: u32,
current_signers: &[Word],
requested_signers: &[Word],
new_threshold: u32,
) -> Result<()> {
let mut requested_sorted: Vec<Word> = requested_signers.to_vec();
let mut current_sorted: Vec<Word> = current_signers.to_vec();
requested_sorted.sort_by_key(word_to_hex);
current_sorted.sort_by_key(word_to_hex);
if requested_sorted != current_sorted {
return Err(MultisigError::InvalidConfig(
"UpdateSigners changes the threshold only; use AddCosigner or RemoveCosigner to change the signer set".to_string(),
));
}
if new_threshold < 1 || new_threshold as usize > current_signers.len() {
return Err(MultisigError::InvalidConfig(format!(
"invalid threshold {}: must be between 1 and {}",
new_threshold,
current_signers.len()
)));
}
if new_threshold == current_threshold {
return Err(MultisigError::InvalidConfig(format!(
"threshold is already {}",
new_threshold
)));
}
Ok(())
}
impl ProposalBuilder {
pub fn new(transaction_type: TransactionType) -> Self {
Self {
transaction_type,
approval_expiration_delta: None,
}
}
pub fn with_options(mut self, options: ProposalOptions) -> Self {
self.approval_expiration_delta = options.approval_expiration_delta;
self
}
pub async fn build(
self,
miden_client: &mut MidenSdkClient,
node_rpc: &Arc<dyn NodeRpcClient>,
guardian_client: &mut GuardianClient,
account: &MultisigAccount,
key_manager: &dyn KeyManager,
) -> Result<Proposal> {
match self.transaction_type {
TransactionType::AddCosigner { new_commitment } => {
self.build_add_cosigner(
miden_client,
guardian_client,
account,
new_commitment,
key_manager,
)
.await
}
TransactionType::RemoveCosigner { commitment } => {
self.build_remove_cosigner(
miden_client,
guardian_client,
account,
commitment,
key_manager,
)
.await
}
TransactionType::P2ID {
recipient,
faucet_id,
amount,
note_type,
heights,
} => {
self.build_p2id(
miden_client,
guardian_client,
account,
recipient,
faucet_id,
amount,
note_type,
heights,
key_manager,
)
.await
}
TransactionType::ConsumeNotes { ref note_ids, .. } => {
self.build_consume_notes(
miden_client,
node_rpc,
guardian_client,
account,
note_ids.clone(),
key_manager,
)
.await
}
TransactionType::SwitchGuardian {
ref new_endpoint,
new_commitment,
} => {
self.build_switch_guardian(
miden_client,
guardian_client,
account,
new_commitment,
new_endpoint.clone(),
key_manager,
)
.await
}
TransactionType::UpdateProcedureThreshold {
procedure,
new_threshold,
} => {
self.build_update_procedure_threshold(
miden_client,
guardian_client,
account,
procedure,
new_threshold,
key_manager,
)
.await
}
TransactionType::UpdateSigners {
new_threshold,
ref signer_commitments,
} => {
self.build_update_signers(
miden_client,
guardian_client,
account,
new_threshold,
signer_commitments,
key_manager,
)
.await
}
TransactionType::Custom => Err(MultisigError::UnsupportedTransactionType(
"cannot create a proposal for a custom transaction type".to_string(),
)),
}
}
fn ensure_response_commitment(proposal: &Proposal, response_commitment: &str) -> Result<()> {
let response_commitment = ensure_hex_prefix(response_commitment);
if hex_body_eq(&proposal.id, &response_commitment) {
return Ok(());
}
Err(MultisigError::GuardianServer(format!(
"GUARDIAN returned proposal commitment {} but transaction summary commitment is {}",
response_commitment, proposal.id
)))
}
async fn fresh_auth_args(&self, miden_client: &MidenSdkClient) -> Result<MultisigAuthArgs> {
proposer_auth_args(
miden_client,
generate_salt(),
self.approval_expiration_delta,
)
.await
}
async fn build_add_cosigner(
&self,
miden_client: &mut MidenSdkClient,
guardian_client: &mut GuardianClient,
account: &MultisigAccount,
new_commitment: Word,
key_manager: &dyn KeyManager,
) -> Result<Proposal> {
let account_id = account.id();
let current_threshold = account.threshold()?;
let mut current_signers = account.cosigner_commitments();
let required_signatures =
account.effective_threshold_for_procedure(ProcedureName::UpdateSigners)? as usize;
current_signers.push(new_commitment);
ensure_admissible_signer_set(¤t_signers, account.guardian_commitment()?)?;
let new_threshold = current_threshold as u64;
let auth_args = self.fresh_auth_args(miden_client).await?;
let salt = auth_args.salt();
let (tx_request, _config_hash) = build_update_signers_transaction_request(
new_threshold,
¤t_signers,
&auth_args,
std::iter::empty(),
key_manager.scheme(),
)?;
let (tx_summary, chain_anchor) =
execute_for_summary(miden_client, account_id, tx_request).await?;
let tx_commitment = tx_summary.to_commitment();
let signer_commitments_hex: Vec<String> = current_signers.iter().map(word_to_hex).collect();
let metadata = ProposalMetadata {
tx_summary_json: Some(tx_summary.to_json()),
proposal_type: None,
new_threshold: Some(new_threshold),
signer_commitments_hex: signer_commitments_hex.clone(),
salt_hex: Some(word_to_hex(&salt)),
recipient_hex: None,
faucet_id_hex: None,
amount: None,
note_type: None,
reclaim_height: None,
timelock_height: None,
note_ids_hex: Vec::new(),
consume_notes_metadata_version: None,
consume_notes_notes: Vec::new(),
new_guardian_pubkey_hex: None,
new_guardian_endpoint: None,
target_procedure: None,
required_signatures: Some(required_signatures),
signers: vec![key_manager.commitment_hex()],
chain_anchor_b64: Some(chain_anchor_to_base64(&chain_anchor)),
};
let payload = ProposalPayload::new(&tx_summary)
.with_signature(key_manager, tx_commitment)
.with_add_signer_metadata(
new_threshold,
signer_commitments_hex.clone(),
word_to_hex(&salt),
)
.with_required_signatures(required_signatures)
.with_chain_anchor(chain_anchor_to_base64(&chain_anchor));
let nonce = account.nonce() + 1;
let response = guardian_client
.push_delta_proposal(&account_id, nonce, &payload.to_json())
.await
.map_err(|e| {
MultisigError::GuardianServer(format!("failed to push proposal: {}", e))
})?;
let proposal = Proposal::new(
tx_summary,
nonce,
TransactionType::AddCosigner { new_commitment },
metadata,
);
Self::ensure_response_commitment(&proposal, &response.commitment)?;
Ok(proposal)
}
async fn build_update_signers(
&self,
miden_client: &mut MidenSdkClient,
guardian_client: &mut GuardianClient,
account: &MultisigAccount,
new_threshold: u32,
requested_signers: &[Word],
key_manager: &dyn KeyManager,
) -> Result<Proposal> {
let account_id = account.id();
let current_threshold = account.threshold()?;
let current_signers = account.cosigner_commitments();
let required_signatures =
account.effective_threshold_for_procedure(ProcedureName::UpdateSigners)? as usize;
validate_threshold_change(
current_threshold,
¤t_signers,
requested_signers,
new_threshold,
)?;
let new_threshold = new_threshold as u64;
let auth_args = self.fresh_auth_args(miden_client).await?;
let salt = auth_args.salt();
let (tx_request, _config_hash) = build_update_signers_transaction_request(
new_threshold,
¤t_signers,
&auth_args,
std::iter::empty(),
key_manager.scheme(),
)?;
let (tx_summary, chain_anchor) =
execute_for_summary(miden_client, account_id, tx_request).await?;
let tx_commitment = tx_summary.to_commitment();
let signer_commitments_hex: Vec<String> = current_signers.iter().map(word_to_hex).collect();
let metadata = ProposalMetadata {
tx_summary_json: Some(tx_summary.to_json()),
proposal_type: Some("change_threshold".to_string()),
new_threshold: Some(new_threshold),
signer_commitments_hex: signer_commitments_hex.clone(),
salt_hex: Some(word_to_hex(&salt)),
recipient_hex: None,
faucet_id_hex: None,
amount: None,
note_type: None,
reclaim_height: None,
timelock_height: None,
note_ids_hex: Vec::new(),
consume_notes_metadata_version: None,
consume_notes_notes: Vec::new(),
new_guardian_pubkey_hex: None,
new_guardian_endpoint: None,
target_procedure: None,
required_signatures: Some(required_signatures),
signers: vec![key_manager.commitment_hex()],
chain_anchor_b64: Some(chain_anchor_to_base64(&chain_anchor)),
};
let payload = ProposalPayload::new(&tx_summary)
.with_signature(key_manager, tx_commitment)
.with_threshold_metadata(
new_threshold,
signer_commitments_hex.clone(),
word_to_hex(&salt),
)
.with_required_signatures(required_signatures)
.with_chain_anchor(chain_anchor_to_base64(&chain_anchor));
let nonce = account.nonce() + 1;
let response = guardian_client
.push_delta_proposal(&account_id, nonce, &payload.to_json())
.await
.map_err(|e| {
MultisigError::GuardianServer(format!("failed to push proposal: {}", e))
})?;
let proposal = Proposal::new(
tx_summary,
nonce,
TransactionType::UpdateSigners {
new_threshold: new_threshold as u32,
signer_commitments: current_signers,
},
metadata,
);
Self::ensure_response_commitment(&proposal, &response.commitment)?;
Ok(proposal)
}
async fn build_remove_cosigner(
&self,
miden_client: &mut MidenSdkClient,
guardian_client: &mut GuardianClient,
account: &MultisigAccount,
commitment_to_remove: Word,
key_manager: &dyn KeyManager,
) -> Result<Proposal> {
let account_id = account.id();
let current_threshold = account.threshold()?;
let current_signers = account.cosigner_commitments();
let required_signatures =
account.effective_threshold_for_procedure(ProcedureName::UpdateSigners)? as usize;
let new_signers: Vec<Word> = current_signers
.iter()
.filter(|&c| c != &commitment_to_remove)
.copied()
.collect();
if new_signers.len() == current_signers.len() {
return Err(MultisigError::InvalidConfig(
"commitment to remove not found in signers".to_string(),
));
}
let new_threshold = std::cmp::min(current_threshold as u64, new_signers.len() as u64);
if new_signers.is_empty() {
return Err(MultisigError::InvalidConfig(
"cannot remove last signer".to_string(),
));
}
let auth_args = self.fresh_auth_args(miden_client).await?;
let salt = auth_args.salt();
let (tx_request, _config_hash) = build_update_signers_transaction_request(
new_threshold,
&new_signers,
&auth_args,
std::iter::empty(),
key_manager.scheme(),
)?;
let (tx_summary, chain_anchor) =
execute_for_summary(miden_client, account_id, tx_request).await?;
let tx_commitment = tx_summary.to_commitment();
let signer_commitments_hex: Vec<String> = new_signers.iter().map(word_to_hex).collect();
let metadata = ProposalMetadata {
tx_summary_json: Some(tx_summary.to_json()),
proposal_type: None,
new_threshold: Some(new_threshold),
signer_commitments_hex: signer_commitments_hex.clone(),
salt_hex: Some(word_to_hex(&salt)),
recipient_hex: None,
faucet_id_hex: None,
amount: None,
note_type: None,
reclaim_height: None,
timelock_height: None,
note_ids_hex: Vec::new(),
consume_notes_metadata_version: None,
consume_notes_notes: Vec::new(),
new_guardian_pubkey_hex: None,
new_guardian_endpoint: None,
target_procedure: None,
required_signatures: Some(required_signatures),
signers: vec![key_manager.commitment_hex()],
chain_anchor_b64: Some(chain_anchor_to_base64(&chain_anchor)),
};
let payload = ProposalPayload::new(&tx_summary)
.with_signature(key_manager, tx_commitment)
.with_remove_signer_metadata(
new_threshold,
signer_commitments_hex.clone(),
word_to_hex(&salt),
)
.with_required_signatures(required_signatures)
.with_chain_anchor(chain_anchor_to_base64(&chain_anchor));
let nonce = account.nonce() + 1;
let response = guardian_client
.push_delta_proposal(&account_id, nonce, &payload.to_json())
.await
.map_err(|e| {
MultisigError::GuardianServer(format!("failed to push proposal: {}", e))
})?;
let proposal = Proposal::new(
tx_summary,
nonce,
TransactionType::RemoveCosigner {
commitment: commitment_to_remove,
},
metadata,
);
Self::ensure_response_commitment(&proposal, &response.commitment)?;
Ok(proposal)
}
#[allow(clippy::too_many_arguments)]
async fn build_p2id(
&self,
miden_client: &mut MidenSdkClient,
guardian_client: &mut GuardianClient,
account: &MultisigAccount,
recipient: AccountId,
faucet_id: AccountId,
amount: u64,
note_type: NoteType,
heights: P2ideHeights,
key_manager: &dyn KeyManager,
) -> Result<Proposal> {
let account_id = account.id();
let required_signatures =
account.effective_threshold_for_procedure(ProcedureName::SendAsset)? as usize;
let asset = build_transfer_asset(faucet_id, amount)?;
let auth_args = self.fresh_auth_args(miden_client).await?;
let salt = auth_args.salt();
let tx_request = build_p2id_transaction_request(
account.inner(),
recipient,
vec![asset.into()],
note_type,
heights,
&auth_args,
std::iter::empty(),
)?;
let (tx_summary, chain_anchor) =
execute_for_summary(miden_client, account_id, tx_request).await?;
let tx_commitment = tx_summary.to_commitment();
let metadata = ProposalMetadata {
tx_summary_json: Some(tx_summary.to_json()),
proposal_type: None,
new_threshold: None,
signer_commitments_hex: Vec::new(),
salt_hex: Some(word_to_hex(&salt)),
recipient_hex: Some(recipient.to_string()),
faucet_id_hex: Some(faucet_id.to_string()),
amount: Some(amount),
note_type: (note_type != NoteType::Public).then(|| note_type.to_string()),
reclaim_height: heights.reclaim,
timelock_height: heights.timelock,
note_ids_hex: Vec::new(),
consume_notes_metadata_version: None,
consume_notes_notes: Vec::new(),
new_guardian_pubkey_hex: None,
new_guardian_endpoint: None,
target_procedure: None,
required_signatures: Some(required_signatures),
signers: vec![key_manager.commitment_hex()],
chain_anchor_b64: Some(chain_anchor_to_base64(&chain_anchor)),
};
let payload = ProposalPayload::new(&tx_summary)
.with_signature(key_manager, tx_commitment)
.with_payment_metadata(
recipient.to_string(),
faucet_id.to_string(),
amount,
word_to_hex(&salt),
note_type,
heights,
)
.with_required_signatures(required_signatures)
.with_chain_anchor(chain_anchor_to_base64(&chain_anchor));
let nonce = account.nonce() + 1;
let response = guardian_client
.push_delta_proposal(&account_id, nonce, &payload.to_json())
.await
.map_err(|e| {
MultisigError::GuardianServer(format!("failed to push proposal: {}", e))
})?;
let proposal = Proposal::new(
tx_summary,
nonce,
TransactionType::P2ID {
recipient,
faucet_id,
amount,
note_type,
heights,
},
metadata,
);
Self::ensure_response_commitment(&proposal, &response.commitment)?;
Ok(proposal)
}
async fn build_consume_notes(
&self,
miden_client: &mut MidenSdkClient,
node_rpc: &Arc<dyn NodeRpcClient>,
guardian_client: &mut GuardianClient,
account: &MultisigAccount,
note_ids: Vec<NoteId>,
key_manager: &dyn KeyManager,
) -> Result<Proposal> {
let account_id = account.id();
let required_signatures =
account.effective_threshold_for_procedure(ProcedureName::ReceiveAsset)? as usize;
let auth_args = self.fresh_auth_args(miden_client).await?;
let salt = auth_args.salt();
let fetched_notes =
crate::transaction::consume::fetch_notes_from_store(miden_client, ¬e_ids).await?;
crate::transaction::ensure_notes_authenticated(miden_client, node_rpc, &fetched_notes)
.await?;
let serialized_notes: Vec<crate::proposal::SerializedNote> = fetched_notes
.iter()
.map(crate::proposal::SerializedNote::from_note)
.collect();
let tx_request = crate::transaction::build_consume_notes_transaction_request_from_notes(
fetched_notes,
&auth_args,
std::iter::empty(),
)?;
let (tx_summary, chain_anchor) =
execute_for_summary(miden_client, account_id, tx_request).await?;
let tx_commitment = tx_summary.to_commitment();
let note_ids_hex: Vec<String> = note_ids.iter().map(|id| id.to_hex()).collect();
let metadata = ProposalMetadata {
tx_summary_json: Some(tx_summary.to_json()),
proposal_type: None,
new_threshold: None,
signer_commitments_hex: Vec::new(),
salt_hex: Some(word_to_hex(&salt)),
recipient_hex: None,
faucet_id_hex: None,
amount: None,
note_type: None,
reclaim_height: None,
timelock_height: None,
note_ids_hex: note_ids_hex.clone(),
consume_notes_metadata_version: Some(
crate::proposal::CONSUME_NOTES_METADATA_VERSION_V2,
),
consume_notes_notes: serialized_notes.clone(),
new_guardian_pubkey_hex: None,
new_guardian_endpoint: None,
target_procedure: None,
required_signatures: Some(required_signatures),
signers: vec![key_manager.commitment_hex()],
chain_anchor_b64: Some(chain_anchor_to_base64(&chain_anchor)),
};
let notes_base64: Vec<String> = serialized_notes
.into_iter()
.map(crate::proposal::SerializedNote::into_inner)
.collect();
let payload = ProposalPayload::new(&tx_summary)
.with_signature(key_manager, tx_commitment)
.with_note_consumption_metadata_v2(note_ids_hex, notes_base64, word_to_hex(&salt))
.with_required_signatures(required_signatures)
.with_chain_anchor(chain_anchor_to_base64(&chain_anchor));
if let Some(meta) = payload.metadata.as_ref() {
let serialized_len = serde_json::to_vec(meta)
.map_err(MultisigError::Serialization)?
.len();
if serialized_len > crate::proposal::MAX_CONSUME_NOTES_METADATA_BYTES {
return Err(MultisigError::ConsumeNotesMetadataOversize {
limit: crate::proposal::MAX_CONSUME_NOTES_METADATA_BYTES,
actual: serialized_len,
});
}
}
let nonce = account.nonce() + 1;
let response = guardian_client
.push_delta_proposal(&account_id, nonce, &payload.to_json())
.await
.map_err(|e| {
MultisigError::GuardianServer(format!("failed to push proposal: {}", e))
})?;
let notes_for_tx_type = metadata.consume_notes_notes.clone();
let proposal = Proposal::new(
tx_summary,
nonce,
TransactionType::ConsumeNotes {
note_ids,
metadata_version: Some(crate::proposal::CONSUME_NOTES_METADATA_VERSION_V2),
notes: notes_for_tx_type,
},
metadata,
);
Self::ensure_response_commitment(&proposal, &response.commitment)?;
Ok(proposal)
}
#[allow(clippy::too_many_arguments)]
async fn build_switch_guardian(
&self,
miden_client: &mut MidenSdkClient,
guardian_client: &mut GuardianClient,
account: &MultisigAccount,
new_guardian_pubkey: Word,
new_guardian_endpoint: String,
key_manager: &dyn KeyManager,
) -> Result<Proposal> {
let account_id = account.id();
let required_signatures =
account.effective_threshold_for_procedure(ProcedureName::UpdateGuardian)? as usize;
ensure_admissible_signer_set(&account.cosigner_commitments(), new_guardian_pubkey)?;
verify_endpoint_commitment(
&new_guardian_endpoint,
new_guardian_pubkey,
key_manager.scheme(),
)
.await?;
let auth_args = self.fresh_auth_args(miden_client).await?;
let salt = auth_args.salt();
let tx_request = build_update_guardian_transaction_request(
new_guardian_pubkey,
key_manager.scheme(),
&auth_args,
std::iter::empty(),
)?;
let (tx_summary, chain_anchor) =
execute_for_summary(miden_client, account_id, tx_request).await?;
let tx_commitment = tx_summary.to_commitment();
let metadata = ProposalMetadata {
tx_summary_json: Some(tx_summary.to_json()),
proposal_type: None,
new_threshold: None,
signer_commitments_hex: Vec::new(),
salt_hex: Some(word_to_hex(&salt)),
recipient_hex: None,
faucet_id_hex: None,
amount: None,
note_type: None,
reclaim_height: None,
timelock_height: None,
note_ids_hex: Vec::new(),
consume_notes_metadata_version: None,
consume_notes_notes: Vec::new(),
new_guardian_pubkey_hex: Some(word_to_hex(&new_guardian_pubkey)),
new_guardian_endpoint: Some(new_guardian_endpoint.clone()),
target_procedure: None,
required_signatures: Some(required_signatures),
signers: vec![key_manager.commitment_hex()],
chain_anchor_b64: Some(chain_anchor_to_base64(&chain_anchor)),
};
let payload = ProposalPayload::new(&tx_summary)
.with_signature(key_manager, tx_commitment)
.with_guardian_update_metadata(
word_to_hex(&new_guardian_pubkey),
new_guardian_endpoint.clone(),
word_to_hex(&salt),
)
.with_required_signatures(required_signatures)
.with_chain_anchor(chain_anchor_to_base64(&chain_anchor));
let nonce = account.nonce() + 1;
let response = guardian_client
.push_delta_proposal(&account_id, nonce, &payload.to_json())
.await
.map_err(|e| {
MultisigError::GuardianServer(format!("failed to push proposal: {}", e))
})?;
let proposal = Proposal::new(
tx_summary,
nonce,
TransactionType::SwitchGuardian {
new_endpoint: new_guardian_endpoint,
new_commitment: new_guardian_pubkey,
},
metadata,
);
Self::ensure_response_commitment(&proposal, &response.commitment)?;
Ok(proposal)
}
async fn build_update_procedure_threshold(
&self,
miden_client: &mut MidenSdkClient,
guardian_client: &mut GuardianClient,
account: &MultisigAccount,
procedure: ProcedureName,
new_threshold: u32,
key_manager: &dyn KeyManager,
) -> Result<Proposal> {
let account_id = account.id();
let required_signatures = account
.effective_threshold_for_procedure(ProcedureName::UpdateProcedureThreshold)?
as usize;
let auth_args = self.fresh_auth_args(miden_client).await?;
let salt = auth_args.salt();
let tx_request = build_update_procedure_threshold_transaction_request(
procedure,
new_threshold,
&auth_args,
std::iter::empty(),
)?;
let (tx_summary, chain_anchor) =
execute_for_summary(miden_client, account_id, tx_request).await?;
let tx_commitment = tx_summary.to_commitment();
let metadata = ProposalMetadata {
tx_summary_json: Some(tx_summary.to_json()),
proposal_type: None,
new_threshold: Some(new_threshold as u64),
signer_commitments_hex: Vec::new(),
salt_hex: Some(word_to_hex(&salt)),
recipient_hex: None,
faucet_id_hex: None,
amount: None,
note_type: None,
reclaim_height: None,
timelock_height: None,
note_ids_hex: Vec::new(),
consume_notes_metadata_version: None,
consume_notes_notes: Vec::new(),
new_guardian_pubkey_hex: None,
new_guardian_endpoint: None,
target_procedure: Some(procedure.to_string()),
required_signatures: Some(required_signatures),
signers: vec![key_manager.commitment_hex()],
chain_anchor_b64: Some(chain_anchor_to_base64(&chain_anchor)),
};
let payload = ProposalPayload::new(&tx_summary)
.with_signature(key_manager, tx_commitment)
.with_procedure_threshold_metadata(procedure, new_threshold as u64, word_to_hex(&salt))
.with_required_signatures(required_signatures)
.with_chain_anchor(chain_anchor_to_base64(&chain_anchor));
let nonce = account.nonce() + 1;
let response = guardian_client
.push_delta_proposal(&account_id, nonce, &payload.to_json())
.await
.map_err(|e| {
MultisigError::GuardianServer(format!("failed to push proposal: {}", e))
})?;
let proposal = Proposal::new(
tx_summary,
nonce,
TransactionType::UpdateProcedureThreshold {
procedure,
new_threshold,
},
metadata,
);
Self::ensure_response_commitment(&proposal, &response.commitment)?;
Ok(proposal)
}
}
#[cfg(test)]
mod tests {
use super::*;
use miden_protocol::account::AccountCodePatch;
use miden_protocol::account::AccountStoragePatch;
use miden_protocol::account::delta::{AccountDelta, AccountVaultDelta};
use miden_protocol::transaction::{
InputNotes, RawOutputNotes, TransactionSummary, TransactionSummaryUserParams,
};
use miden_protocol::{Felt, ZERO};
fn signer(byte: u8) -> Word {
crate::keystore::word_from_hex(&format!("0x{}", format!("{byte:02x}").repeat(32)))
.expect("valid word")
}
#[test]
fn threshold_change_accepts_the_same_set_in_any_order() {
let current = vec![signer(1), signer(2), signer(3)];
let reordered = vec![signer(3), signer(1), signer(2)];
assert!(validate_threshold_change(2, ¤t, &reordered, 3).is_ok());
}
#[test]
fn threshold_change_rejects_a_membership_change() {
let current = vec![signer(1), signer(2)];
let with_extra = vec![signer(1), signer(2), signer(3)];
let error = validate_threshold_change(2, ¤t, &with_extra, 2)
.expect_err("a different signer set is not a threshold change");
assert!(error.to_string().contains("AddCosigner or RemoveCosigner"));
let swapped = vec![signer(1), signer(9)];
assert!(validate_threshold_change(2, ¤t, &swapped, 1).is_err());
}
#[test]
fn threshold_change_rejects_a_threshold_outside_the_signer_count() {
let current = vec![signer(1), signer(2), signer(3)];
assert!(validate_threshold_change(2, ¤t, ¤t, 4).is_err());
assert!(validate_threshold_change(2, ¤t, ¤t, 0).is_err());
assert!(validate_threshold_change(2, ¤t, ¤t, 3).is_ok());
assert!(validate_threshold_change(2, ¤t, ¤t, 1).is_ok());
}
#[test]
fn threshold_change_rejects_a_no_op() {
let current = vec![signer(1), signer(2), signer(3)];
let error = validate_threshold_change(2, ¤t, ¤t, 2)
.expect_err("proposing the threshold it already has spends a quorum for nothing");
assert!(error.to_string().contains("already"));
}
fn test_proposal() -> Proposal {
let account_id =
AccountId::from_hex("0x7b7b7b7a7b7b7b017b7b7b7b7b7b7b").expect("valid account id");
let account_delta = AccountDelta::new(
account_id,
AccountStoragePatch::default(),
AccountVaultDelta::default(),
AccountCodePatch::default(),
Felt::ZERO,
)
.expect("valid delta");
let tx_summary = TransactionSummary::new(
account_delta,
InputNotes::new(Vec::new()).expect("empty input notes"),
RawOutputNotes::new(Vec::new()).expect("empty output notes"),
miden_protocol::block::BlockNumber::from(0),
Word::default(),
0,
TransactionSummaryUserParams::new([
ZERO,
ZERO,
Felt::new_unchecked(9),
ZERO,
ZERO,
ZERO,
]),
);
Proposal::new(
tx_summary,
1,
TransactionType::ConsumeNotes {
note_ids: vec![miden_protocol::note::NoteId::from_raw(Word::from([
Felt::new_unchecked(1),
ZERO,
ZERO,
ZERO,
]))],
metadata_version: None,
notes: Vec::new(),
},
ProposalMetadata {
note_ids_hex: vec![
"0x0100000000000000000000000000000000000000000000000000000000000000"
.to_string(),
],
required_signatures: Some(1),
..Default::default()
},
)
}
#[test]
fn ensure_response_commitment_rejects_mismatch() {
let proposal = test_proposal();
let result = ProposalBuilder::ensure_response_commitment(
&proposal,
"0x0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20",
);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("transaction summary commitment")
);
}
mod multisig_auth_args {
use super::*;
use crate::client::test_support::{guarded_multisig_account, p2id_note_for, test_wallet};
use guardian_shared::SignatureScheme;
use miden_client::transaction::TransactionRequest;
use miden_protocol::asset::FungibleAsset;
use miden_protocol::block::BlockNumber;
use miden_protocol::crypto::SequentialCommit;
use miden_standards::account::auth::MultisigAuthArgs;
fn auth_args() -> MultisigAuthArgs {
MultisigAuthArgs::new(BlockNumber::from(7), Word::from([1u32, 2, 3, 4]))
}
fn assert_carries_auth_args(request: &TransactionRequest) {
let commitment = auth_args().to_commitment();
assert_eq!(request.fee_conversion_salt(), None);
assert_eq!(*request.auth_arg(), Some(commitment));
let preimage = request
.advice_map()
.get(&commitment)
.expect("the auth-args preimage is in the advice map");
assert_eq!(preimage.to_vec(), auth_args().to_elements());
assert_eq!(
request.block_numbers().iter().copied().collect::<Vec<_>>(),
vec![auth_args().bound_block_num()],
"the request declares exactly the block its auth args bind"
);
}
#[test]
fn signer_update_request_carries_the_auth_args() {
let (request, _) = build_update_signers_transaction_request(
1,
&[Word::from([5u32, 6, 7, 8])],
&auth_args(),
std::iter::empty(),
SignatureScheme::Falcon,
)
.expect("the signer-update request builds");
assert_carries_auth_args(&request);
}
#[test]
fn p2id_request_carries_the_auth_args() {
let recipient = AccountId::from_hex("0x7b7b7b7a7b7b7b017b7b7b7b7b7b7b")
.expect("valid recipient id");
let request = build_p2id_transaction_request(
&guarded_multisig_account(),
recipient,
vec![FungibleAsset::mock(100)],
NoteType::Public,
P2ideHeights::default(),
&auth_args(),
std::iter::empty(),
)
.expect("the p2id request builds");
assert_carries_auth_args(&request);
}
#[test]
fn consume_notes_request_carries_the_auth_args() {
let note = p2id_note_for(&test_wallet(1), 1, NoteType::Public);
let request = crate::transaction::build_consume_notes_transaction_request_from_notes(
vec![note],
&auth_args(),
std::iter::empty(),
)
.expect("the consume-notes request builds");
assert_carries_auth_args(&request);
}
#[test]
fn switch_guardian_request_carries_the_auth_args() {
let request = build_update_guardian_transaction_request(
Word::from([1u32, 1, 1, 1]),
SignatureScheme::Falcon,
&auth_args(),
std::iter::empty(),
)
.expect("the switch-guardian request builds");
assert_carries_auth_args(&request);
}
#[test]
fn update_procedure_threshold_request_carries_the_auth_args() {
let request = build_update_procedure_threshold_transaction_request(
ProcedureName::SendAsset,
2,
&auth_args(),
std::iter::empty(),
)
.expect("the procedure-threshold request builds");
assert_carries_auth_args(&request);
}
}
}
#[cfg(test)]
mod signer_set_guard {
use super::*;
fn signer(n: u32) -> Word {
Word::from([n, n, n, n])
}
#[test]
fn accepts_distinct_signers_without_the_guardian() {
ensure_admissible_signer_set(&[signer(1), signer(2)], signer(9)).unwrap();
}
#[test]
fn rejects_more_signers_than_the_account_holds() {
let signers: Vec<Word> = (1..=u32::from(ApproverSet::MAX_APPROVERS) + 1)
.map(signer)
.collect();
let error = ensure_admissible_signer_set(&signers, signer(0))
.unwrap_err()
.to_string();
assert!(error.contains("too many signers"), "{error}");
}
#[test]
fn rejects_a_repeated_signer() {
let error = ensure_admissible_signer_set(&[signer(1), signer(1)], signer(9))
.unwrap_err()
.to_string();
assert!(error.contains("duplicate signer commitment"), "{error}");
}
#[test]
fn rejects_the_guardian_among_the_signers() {
let error = ensure_admissible_signer_set(&[signer(1), signer(9)], signer(9))
.unwrap_err()
.to_string();
assert!(
error.contains("GUARDIAN commitment must be different"),
"{error}"
);
}
}