use std::collections::HashSet;
use guardian_shared::{ProposalSignature, ToJson};
use miden_client::transaction::TransactionRequest;
use super::{MultisigClient, ProposalResult};
use crate::error::{MultisigError, Result};
use crate::execution::{
SignatureAdvice, SignatureInput, build_final_transaction_request, collect_signature_advice,
};
use crate::keystore::proposal_public_key_hex;
use crate::proposal::{Proposal, TransactionType, is_builtin_proposal_type};
use crate::transaction::{
ProposalBuilder, deserialize_transaction_request, execute_for_summary, word_to_hex,
};
impl MultisigClient {
async fn get_proposal(
&mut self,
account_id: &miden_protocol::account::AccountId,
proposal_id: &str,
) -> Result<Proposal> {
let mut guardian_client = self.create_authenticated_guardian_client().await?;
let response = guardian_client
.get_delta_proposal(account_id, proposal_id)
.await
.map_err(|e| MultisigError::GuardianServer(format!("failed to get proposal: {}", e)))?;
let raw_proposal = response
.proposal
.ok_or_else(|| MultisigError::ProposalNotFound(proposal_id.to_string()))?;
Self::ensure_proposal_account_id(&raw_proposal.account_id, account_id)?;
let proposal = Proposal::from(&raw_proposal)?;
self.verify_proposal_summary_binding(&proposal).await?;
Ok(proposal)
}
pub async fn list_proposals(&mut self) -> Result<Vec<Proposal>> {
let (account_id, current_nonce) = {
let account = self.require_account()?;
(account.id(), account.nonce())
};
let mut guardian_client = self.create_authenticated_guardian_client().await?;
let response = guardian_client
.get_delta_proposals(&account_id)
.await
.map_err(|e| {
MultisigError::GuardianServer(format!("failed to get proposals: {}", e))
})?;
let mut proposals = Vec::with_capacity(response.proposals.len());
for delta in &response.proposals {
Self::ensure_proposal_account_id(&delta.account_id, &account_id)?;
let proposal = Proposal::from(delta)?;
if proposal.nonce <= current_nonce {
continue;
}
self.verify_proposal_summary_binding(&proposal).await?;
proposals.push(proposal);
}
Ok(proposals)
}
pub async fn sign_proposal(&mut self, proposal_id: &str) -> Result<Proposal> {
let account = self.require_account()?;
let user_commitment = self.key_manager.commitment();
if !account.is_cosigner(&user_commitment) {
return Err(MultisigError::NotCosigner);
}
let account_id = account.id();
let proposal = self.get_proposal(&account_id, proposal_id).await?;
if proposal.has_signed(&self.key_manager.commitment_hex()) {
return Err(MultisigError::AlreadySigned);
}
let tx_commitment = proposal.tx_summary.to_commitment();
let signature_hex = self.key_manager.sign_word_hex(tx_commitment);
let signature = ProposalSignature::from_scheme(
self.key_manager.scheme(),
signature_hex,
proposal_public_key_hex(self.key_manager.as_ref()),
);
let mut guardian_client = self.create_authenticated_guardian_client().await?;
let sign_response = guardian_client
.sign_delta_proposal(&account_id, proposal_id, signature)
.await
.map_err(|e| {
MultisigError::GuardianServer(format!("failed to sign proposal: {}", e))
})?;
let updated_raw = sign_response
.delta
.as_ref()
.ok_or_else(|| MultisigError::ProposalNotFound(proposal_id.to_string()))?;
Self::ensure_proposal_account_id(&updated_raw.account_id, &account_id)?;
let updated = Proposal::from(updated_raw)?;
Ok(updated)
}
pub async fn execute_proposal(&mut self, proposal_id: &str) -> Result<()> {
self.sync().await?;
let account = self.require_account()?.clone();
let account_id = account.id();
let proposal = self.get_proposal(&account_id, proposal_id).await?;
if proposal.nonce <= account.nonce() {
return Err(MultisigError::InvalidConfig(format!(
"proposal nonce {} is not greater than the current account nonce {}; \
it has already been executed or superseded",
proposal.nonce,
account.nonce()
)));
}
if !proposal.status.is_ready() {
let (collected, required) = proposal.signature_counts();
return Err(MultisigError::ProposalNotReady {
collected,
required,
});
}
if matches!(proposal.transaction_type, TransactionType::Custom) {
return Err(MultisigError::UnsupportedTransactionType(
"custom proposals are executed by the integration; call \
prepare_custom_execution to get the cosigner + GUARDIAN advice"
.to_string(),
));
}
let tx_summary_commitment = proposal.tx_summary.to_commitment();
let mut signature_inputs: Vec<SignatureInput> = proposal
.signatures
.into_iter()
.map(|signature| SignatureInput {
signer_commitment: signature.signer_commitment,
signature_hex: signature.signature_hex,
scheme: signature.scheme,
public_key_hex: signature.public_key_hex,
})
.collect();
signature_inputs.sort_by(|a, b| a.signer_commitment.cmp(&b.signer_commitment));
signature_inputs.dedup_by(|a, b| a.signer_commitment == b.signer_commitment);
let required_commitments: HashSet<String> =
account.cosigner_commitments_hex().into_iter().collect();
let mut signature_advice = collect_signature_advice(
signature_inputs,
&required_commitments,
tx_summary_commitment,
)?;
if proposal.transaction_type.requires_guardian_ack() {
let guardian_advice = self
.get_guardian_ack_signature(
&account,
proposal.nonce,
&proposal.tx_summary,
tx_summary_commitment,
)
.await?;
signature_advice.push(guardian_advice);
} else {
let _ = self
.get_guardian_ack_signature(
&account,
proposal.nonce,
&proposal.tx_summary,
tx_summary_commitment,
)
.await;
}
let salt = proposal.metadata.salt()?;
let signer_commitments = if matches!(
&proposal.transaction_type,
TransactionType::AddCosigner { .. }
| TransactionType::RemoveCosigner { .. }
| TransactionType::UpdateSigners { .. }
) {
Some(proposal.metadata.signer_commitments()?)
} else {
proposal.metadata.signer_commitments().ok()
};
let final_tx_request = build_final_transaction_request(
&self.miden_client,
&proposal.transaction_type,
account.inner(),
salt,
signature_advice,
proposal.metadata.new_threshold,
signer_commitments.as_deref(),
self.key_manager.scheme(),
)
.await?;
self.finalize_transaction(account_id, final_tx_request, &proposal.transaction_type)
.await
}
pub async fn propose_custom_transaction(
&mut self,
transaction_request_bytes: &[u8],
proposal_type: &str,
) -> Result<Proposal> {
let proposal_type = proposal_type.trim().to_lowercase();
if proposal_type.is_empty() {
return Err(MultisigError::InvalidConfig(
"proposal_type must not be empty".to_string(),
));
}
if !proposal_type
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')
{
return Err(MultisigError::InvalidConfig(format!(
"proposal_type '{}' must be lowercase snake_case ([a-z0-9_]): no spaces, hyphens, or other characters",
proposal_type
)));
}
if is_builtin_proposal_type(&proposal_type) {
return Err(MultisigError::UnsupportedTransactionType(format!(
"'{}' is a built-in proposal type; use the typed proposal API instead",
proposal_type
)));
}
self.sync().await?;
let account = self.require_account()?.clone();
let account_id = account.id();
let tx_request = deserialize_transaction_request(transaction_request_bytes)?;
let tx_summary =
execute_for_summary(&mut self.miden_client, account_id, tx_request).await?;
let tx_commitment = tx_summary.to_commitment();
let required_signatures = account.threshold()? as usize;
let metadata = crate::proposal::ProposalMetadata {
tx_summary_json: Some(tx_summary.to_json()),
proposal_type: Some(proposal_type.to_string()),
required_signatures: Some(required_signatures),
signers: vec![self.key_manager.commitment_hex()],
..Default::default()
};
let payload = crate::payload::ProposalPayload::new(&tx_summary)
.with_signature(self.key_manager.as_ref(), tx_commitment)
.with_custom_metadata(proposal_type.to_string())
.with_required_signatures(required_signatures);
let nonce = account.nonce() + 1;
let mut guardian_client = self.create_authenticated_guardian_client().await?;
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::Custom, metadata);
if !proposal
.id
.trim_start_matches("0x")
.eq_ignore_ascii_case(response.commitment.trim_start_matches("0x"))
{
return Err(MultisigError::GuardianServer(format!(
"GUARDIAN returned proposal commitment {} but expected {}",
response.commitment, proposal.id
)));
}
Ok(proposal)
}
pub async fn prepare_custom_execution(
&mut self,
proposal_id: &str,
transaction_request_bytes: &[u8],
) -> Result<Vec<SignatureAdvice>> {
self.sync().await?;
let account = self.require_account()?.clone();
let account_id = account.id();
let proposal = self.get_proposal(&account_id, proposal_id).await?;
if !matches!(proposal.transaction_type, TransactionType::Custom) {
return Err(MultisigError::UnsupportedTransactionType(
"prepare_custom_execution is only for custom proposals; use execute_proposal \
for built-in types"
.to_string(),
));
}
if !proposal.status.is_ready() {
let (collected, required) = proposal.signature_counts();
return Err(MultisigError::ProposalNotReady {
collected,
required,
});
}
let tx_summary_commitment = proposal.tx_summary.to_commitment();
let probe_request = deserialize_transaction_request(transaction_request_bytes)?;
let derived_summary =
execute_for_summary(&mut self.miden_client, account_id, probe_request).await?;
let derived_commitment = derived_summary.to_commitment();
if derived_commitment != tx_summary_commitment {
return Err(MultisigError::InvalidConfig(format!(
"transaction request does not match the signed proposal commitment \
(expected {}, got {})",
word_to_hex(&tx_summary_commitment),
word_to_hex(&derived_commitment)
)));
}
let mut signature_inputs: Vec<SignatureInput> = proposal
.signatures
.into_iter()
.map(|signature| SignatureInput {
signer_commitment: signature.signer_commitment,
signature_hex: signature.signature_hex,
scheme: signature.scheme,
public_key_hex: signature.public_key_hex,
})
.collect();
signature_inputs.sort_by(|a, b| a.signer_commitment.cmp(&b.signer_commitment));
signature_inputs.dedup_by(|a, b| a.signer_commitment == b.signer_commitment);
let required_commitments: HashSet<String> =
account.cosigner_commitments_hex().into_iter().collect();
let mut signature_advice = collect_signature_advice(
signature_inputs,
&required_commitments,
tx_summary_commitment,
)?;
if proposal.transaction_type.requires_guardian_ack() {
let guardian_advice = self
.get_guardian_ack_signature(
&account,
proposal.nonce,
&derived_summary,
tx_summary_commitment,
)
.await?;
signature_advice.push(guardian_advice);
}
Ok(signature_advice)
}
pub async fn submit_transaction(&mut self, request: TransactionRequest) -> Result<()> {
self.sync().await?;
let account_id = self.require_account()?.id();
self.miden_client
.submit_new_transaction(account_id, request)
.await
.map_err(|e| {
MultisigError::TransactionExecution(format!(
"transaction submission failed: {:?}",
e
))
})?;
let _ = self.miden_client.sync_state().await;
Ok(())
}
pub async fn propose_transaction(
&mut self,
transaction_type: TransactionType,
) -> Result<Proposal> {
self.sync().await?;
let account = self.require_account()?.clone();
let mut guardian_client = self.create_authenticated_guardian_client().await?;
ProposalBuilder::new(transaction_type)
.build(
&mut self.miden_client,
&mut guardian_client,
&account,
self.key_manager.as_ref(),
)
.await
}
pub async fn propose_with_fallback(
&mut self,
transaction_type: TransactionType,
) -> Result<ProposalResult> {
match self.propose_transaction(transaction_type.clone()).await {
Ok(proposal) => Ok(ProposalResult::Online(Box::new(proposal))),
Err(
error @ (MultisigError::GuardianConnection(_) | MultisigError::GuardianServer(_)),
) => {
if transaction_type.supports_offline_execution() {
let exported = self.create_proposal_offline(transaction_type).await?;
Ok(ProposalResult::Offline(Box::new(exported)))
} else {
Err(error)
}
}
Err(e) => Err(e),
}
}
}
#[cfg(test)]
mod tests {
use guardian_client::DeltaObject;
use guardian_shared::ToJson;
use miden_protocol::account::AccountId;
use miden_protocol::account::delta::{AccountDelta, AccountStorageDelta, AccountVaultDelta};
use miden_protocol::transaction::{InputNotes, RawOutputNotes, TransactionSummary};
use miden_protocol::{Felt, Word, ZERO};
use crate::error::{MultisigError, Result};
use crate::proposal::Proposal;
fn create_test_tx_summary(account_id: &str, seed: u64) -> TransactionSummary {
let account_id = AccountId::from_hex(account_id).expect("valid account id");
let account_delta = AccountDelta::new(
account_id,
AccountStorageDelta::default(),
AccountVaultDelta::default(),
Felt::ZERO,
)
.expect("valid delta");
TransactionSummary::new(
account_delta,
InputNotes::new(Vec::new()).expect("empty input notes"),
RawOutputNotes::new(Vec::new()).expect("empty output notes"),
Word::from([Felt::new_unchecked(seed), ZERO, ZERO, ZERO]),
)
}
fn proposal_delta(
account_id: &str,
nonce: u64,
new_commitment: &str,
seed: u64,
) -> DeltaObject {
let payload = serde_json::json!({
"tx_summary": create_test_tx_summary(account_id, seed).to_json(),
"signatures": [],
"metadata": {
"proposal_type": "switch_guardian",
"required_signatures": 1,
"new_guardian_pubkey": "0x0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20",
"new_guardian_endpoint": "http://new-guardian.example.com"
}
});
DeltaObject {
account_id: account_id.to_string(),
nonce,
prev_commitment: "0x000".to_string(),
delta_payload: serde_json::to_string(&payload).expect("payload serialization"),
new_commitment: new_commitment.to_string(),
ack_sig: String::new(),
ack_pubkey: None,
ack_scheme: None,
candidate_at: String::new(),
canonical_at: None,
discarded_at: None,
status: None,
}
}
#[test]
fn inline_iteration_selects_by_unique_id_when_nonce_collides() {
let same_nonce = 42;
let delta_a = proposal_delta("0x7b7b7b7a7b7b7b017b7b7b7b7b7b7b", same_nonce, "0xaaa", 1);
let delta_b = proposal_delta("0x7c7c7c7c7c7c7c017c7c7c7c7c7c7c", same_nonce, "0xbbb", 2);
let target = Proposal::from(&delta_b).expect("proposal parses");
let proposals = [delta_a, delta_b.clone()];
let mut matched: Option<(&DeltaObject, Proposal)> = None;
for raw_proposal in &proposals {
let parsed = Proposal::from(raw_proposal).expect("parses");
if parsed.id == target.id {
matched = Some((raw_proposal, parsed));
}
}
let (raw, parsed) = matched.expect("proposal should be found");
assert_eq!(parsed.id, target.id);
assert_eq!(parsed.nonce, same_nonce);
assert_eq!(raw.new_commitment, delta_b.new_commitment);
}
#[test]
fn inline_iteration_rejects_duplicate_ids() {
let delta = proposal_delta("0x7b7b7b7a7b7b7b017b7b7b7b7b7b7b", 42, "0xaaa", 1);
let proposal_id = Proposal::from(&delta).expect("proposal parses").id;
let mut matched: Option<(&DeltaObject, Proposal)> = None;
let err = (&[delta.clone(), delta] as &[DeltaObject])
.iter()
.try_for_each(|raw_proposal| -> Result<()> {
let parsed = Proposal::from(raw_proposal)?;
if parsed.id == proposal_id {
if matched.is_some() {
return Err(MultisigError::InvalidConfig(format!(
"multiple proposals returned with the same ID {}",
proposal_id
)));
}
matched = Some((raw_proposal, parsed));
}
Ok(())
})
.expect_err("duplicate ids should fail");
match err {
MultisigError::InvalidConfig(message) => {
assert!(message.contains("multiple proposals returned with the same ID"));
}
other => panic!("unexpected error: {other:?}"),
}
}
}