miden-multisig-client 0.15.0

High-level SDK for interacting with multisig accounts on Miden via GUARDIAN
Documentation
//! Shared execution logic for proposal finalization.

use std::collections::HashSet;

use guardian_shared::SignatureScheme;
use miden_client::account::Account;
use miden_client::transaction::TransactionRequest;
use miden_protocol::account::AccountId;
use miden_protocol::asset::{Asset, AssetCallbackFlag, FungibleAsset};
use miden_protocol::{Felt, Word};

use crate::MidenSdkClient;
use crate::error::{MultisigError, Result};
use crate::keystore::{ensure_hex_prefix, word_from_hex};
use crate::proposal::TransactionType;

/// Signature advice entry: (key, prepared_signature_values)
pub type SignatureAdvice = (Word, Vec<Felt>);

/// Input for collecting a signature into advice format.
pub struct SignatureInput {
    /// Hex-encoded signer commitment (with or without 0x prefix).
    pub signer_commitment: String,
    /// Hex-encoded signature (with or without 0x prefix).
    pub signature_hex: String,
    /// Signature scheme (falcon or ecdsa).
    pub scheme: SignatureScheme,
    /// Hex-encoded public key (required for ECDSA signatures).
    pub public_key_hex: Option<String>,
}

/// Collects and validates cosigner signatures into advice entries.
///
/// Filters signatures to only include those from required signers, skips duplicates,
/// and converts to the format needed for transaction advice.
///
/// # Arguments
/// * `signatures` - Raw signature inputs to process
/// * `required_commitments` - Set of valid signer commitments (lowercase hex)
/// * `tx_summary_commitment` - The transaction summary commitment being signed
///
/// # Returns
/// Vector of (key, prepared_signature) tuples for transaction advice.
pub fn collect_signature_advice(
    signatures: impl IntoIterator<Item = SignatureInput>,
    required_commitments: &HashSet<String>,
    tx_summary_commitment: Word,
) -> Result<Vec<SignatureAdvice>> {
    let mut advice = Vec::new();
    let mut added_signers: HashSet<String> = HashSet::new();

    for sig_input in signatures {
        if !required_commitments
            .iter()
            .any(|c| c.eq_ignore_ascii_case(&sig_input.signer_commitment))
        {
            continue;
        }

        // Skip duplicates
        let signer_lower = sig_input.signer_commitment.to_lowercase();
        if !added_signers.insert(signer_lower) {
            continue;
        }

        let commitment =
            word_from_hex(&sig_input.signer_commitment).map_err(MultisigError::HexDecode)?;

        let signature = sig_input
            .scheme
            .parse_signature_hex(&ensure_hex_prefix(&sig_input.signature_hex))
            .map_err(MultisigError::Signature)?;
        let entry = sig_input
            .scheme
            .build_signature_advice_entry(
                commitment,
                tx_summary_commitment,
                &signature,
                sig_input.public_key_hex.as_deref(),
            )
            .map_err(MultisigError::Signature)?;
        advice.push(entry);
    }

    Ok(advice)
}

/// Builds the fungible asset to transfer, sourcing the callback flag from the held asset.
///
/// In Miden 0.15 the callback flag is part of the vault key, so rebuilding the asset from
/// `faucet_id`/`amount` with the default flag would not match the held asset and the transfer would
/// abort. When the faucet is absent the default flag is used, surfacing the missing-asset error.
pub fn build_transfer_asset(
    account: &Account,
    faucet_id: AccountId,
    amount: u64,
) -> Result<FungibleAsset> {
    let callbacks = held_callback_flag(account.vault().assets(), faucet_id);

    FungibleAsset::new(faucet_id, amount)
        .map(|asset| asset.with_callbacks(callbacks))
        .map_err(|e| MultisigError::InvalidConfig(format!("failed to create asset: {}", e)))
}

/// Returns the callback flag of the held fungible asset issued by `faucet_id`,
/// defaulting to `Disabled` when the faucet's asset is not present in the vault.
fn held_callback_flag(
    held_assets: impl IntoIterator<Item = Asset>,
    faucet_id: AccountId,
) -> AssetCallbackFlag {
    held_assets
        .into_iter()
        .find_map(|asset| match asset {
            Asset::Fungible(fungible) if fungible.faucet_id() == faucet_id => {
                Some(fungible.callbacks())
            }
            _ => None,
        })
        .unwrap_or_default()
}

/// Builds the final transaction request based on transaction type.
#[expect(
    clippy::too_many_arguments,
    reason = "execution needs transaction metadata and signature scheme to stay explicit"
)]
pub async fn build_final_transaction_request(
    client: &MidenSdkClient,
    transaction_type: &TransactionType,
    account: &Account,
    salt: Word,
    signature_advice: Vec<SignatureAdvice>,
    metadata_threshold: Option<u64>,
    metadata_signer_commitments: Option<&[Word]>,
    scheme: SignatureScheme,
) -> Result<TransactionRequest> {
    match transaction_type {
        TransactionType::P2ID {
            recipient,
            faucet_id,
            amount,
        } => {
            let asset = build_transfer_asset(account, *faucet_id, *amount)?;

            crate::transaction::build_p2id_transaction_request(
                account,
                *recipient,
                vec![asset.into()],
                salt,
                signature_advice,
            )
        }
        TransactionType::ConsumeNotes {
            note_ids,
            metadata_version,
            notes,
        } => {
            // v1/v2 dispatch for issue #229 / spec FR-009.
            match metadata_version {
                Some(crate::proposal::CONSUME_NOTES_METADATA_VERSION_V2) => {
                    if notes.len() != note_ids.len() {
                        return Err(MultisigError::NoteBindingMismatch(format!(
                            "consume_notes v2: notes.len()={} does not match note_ids.len()={}",
                            notes.len(),
                            note_ids.len()
                        )));
                    }
                    let mut decoded: Vec<miden_protocol::note::Note> =
                        Vec::with_capacity(notes.len());
                    for (i, serialized) in notes.iter().enumerate() {
                        let note = serialized.to_note()?;
                        if note.id() != note_ids[i] {
                            return Err(MultisigError::NoteBindingMismatch(format!(
                                "consume_notes v2: notes[{}] id {} != note_ids[{}] {}",
                                i,
                                note.id().to_hex(),
                                i,
                                note_ids[i].to_hex()
                            )));
                        }
                        decoded.push(note);
                    }
                    crate::transaction::build_consume_notes_transaction_request_from_notes(
                        decoded,
                        salt,
                        signature_advice,
                    )
                }
                None | Some(1) => {
                    #[cfg(feature = "legacy-consume-notes")]
                    {
                        crate::transaction::build_consume_notes_transaction_request(
                            client,
                            note_ids.clone(),
                            salt,
                            signature_advice,
                        )
                        .await
                    }
                    #[cfg(not(feature = "legacy-consume-notes"))]
                    {
                        let _ = (client, salt, signature_advice);
                        // Preserve `Some(1)` vs `None` so the error tells the
                        // operator which legacy shape was rejected.
                        Err(MultisigError::UnsupportedMetadataVersion {
                            found: *metadata_version,
                        })
                    }
                }
                Some(other) => Err(MultisigError::UnsupportedMetadataVersion {
                    found: Some(*other),
                }),
            }
        }
        TransactionType::SwitchGuardian { new_commitment, .. } => {
            crate::transaction::build_update_guardian_transaction_request(
                *new_commitment,
                salt,
                signature_advice,
            )
        }
        TransactionType::UpdateProcedureThreshold {
            procedure,
            new_threshold,
        } => {
            let (tx_request, _) =
                crate::transaction::build_update_procedure_threshold_transaction_request(
                    *procedure,
                    *new_threshold,
                    salt,
                    signature_advice,
                    scheme,
                )?;

            Ok(tx_request)
        }
        TransactionType::AddCosigner { .. }
        | TransactionType::RemoveCosigner { .. }
        | TransactionType::UpdateSigners { .. } => {
            // Signer update transactions need threshold and signer commitments from metadata
            let signer_commitments = metadata_signer_commitments.ok_or_else(|| {
                MultisigError::MissingConfig("signer_commitments for signer update".to_string())
            })?;
            let new_threshold = metadata_threshold
                .ok_or_else(|| MultisigError::MissingConfig("new_threshold".to_string()))?;

            let (tx_request, _) = crate::transaction::build_update_signers_transaction_request(
                new_threshold,
                signer_commitments,
                salt,
                signature_advice,
                scheme,
            )?;

            Ok(tx_request)
        }
        TransactionType::Custom => Err(MultisigError::UnsupportedTransactionType(
            "cannot build a transaction for a custom proposal type".to_string(),
        )),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use miden_client::Serializable;
    use miden_protocol::crypto::dsa::falcon512_poseidon2::SecretKey;

    #[test]
    fn test_collect_signature_advice_filters_by_required() {
        let required: HashSet<String> = ["0xabc", "0xdef"].iter().map(|s| s.to_string()).collect();

        // Note: This test validates the filtering logic structure.
        // Full integration requires valid signatures which need real keys.

        let signatures = vec![SignatureInput {
            signer_commitment: "0xunknown".to_string(),
            signature_hex: "0x1234".to_string(),
            scheme: SignatureScheme::Falcon,
            public_key_hex: None,
        }];

        // Unknown signer should be filtered out
        let result = collect_signature_advice(signatures, &required, Word::default());
        // This will fail on signature parsing, but validates filtering happens first
        // In production, only valid signatures would be provided
        assert!(result.is_ok()); // Empty vec since unknown was filtered
        assert!(result.unwrap().is_empty());
    }

    #[test]
    fn test_collect_signature_advice_skips_duplicates() {
        let required: HashSet<String> = ["0xabc"].iter().map(|s| s.to_string()).collect();

        let signatures = vec![
            SignatureInput {
                signer_commitment: "0xABC".to_string(), // uppercase
                signature_hex: "0x1234".to_string(),
                scheme: SignatureScheme::Falcon,
                public_key_hex: None,
            },
            SignatureInput {
                signer_commitment: "0xabc".to_string(), // lowercase duplicate
                signature_hex: "0x5678".to_string(),
                scheme: SignatureScheme::Falcon,
                public_key_hex: None,
            },
        ];

        // Both will fail signature parsing, but second should be deduplicated
        // before reaching that point (based on lowercase comparison)
        let result = collect_signature_advice(signatures, &required, Word::default());
        // Will error on first sig parse since it's not a valid Falcon sig,
        // but the dedup logic is what we're testing
        assert!(result.is_err()); // Error on invalid sig, but only one attempt
    }

    #[test]
    fn test_collect_signature_advice_with_valid_signature() {
        let secret_key = SecretKey::new();
        let public_key = secret_key.public_key();
        let commitment = public_key.to_commitment();
        let commitment_hex = format!("0x{}", hex::encode(commitment.to_bytes()));

        let msg = Word::default();
        let signature = secret_key.sign(msg);
        let signature_hex = format!("0x{}", hex::encode(signature.to_bytes()));

        let required: HashSet<String> = [commitment_hex.clone()].into_iter().collect();
        let signatures = vec![SignatureInput {
            signer_commitment: commitment_hex,
            signature_hex,
            scheme: SignatureScheme::Falcon,
            public_key_hex: None,
        }];

        let advice = collect_signature_advice(signatures, &required, msg).expect("valid advice");
        assert_eq!(advice.len(), 1);
    }

    fn faucet(id: u128) -> AccountId {
        AccountId::try_from(id).expect("valid faucet id")
    }

    #[test]
    fn held_callback_flag_preserves_enabled_flag_from_vault() {
        use miden_client::testing::account_id::ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1;

        let faucet_id = faucet(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1);
        let held = FungibleAsset::new(faucet_id, 100)
            .unwrap()
            .with_callbacks(AssetCallbackFlag::Enabled);

        let flag = held_callback_flag([Asset::Fungible(held)], faucet_id);
        assert_eq!(flag, AssetCallbackFlag::Enabled);
    }

    #[test]
    fn held_callback_flag_defaults_to_disabled_when_faucet_absent() {
        use miden_client::testing::account_id::{
            ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1, ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_2,
        };

        let held = FungibleAsset::new(faucet(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1), 100)
            .unwrap()
            .with_callbacks(AssetCallbackFlag::Enabled);

        let flag = held_callback_flag(
            [Asset::Fungible(held)],
            faucet(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_2),
        );
        assert_eq!(flag, AssetCallbackFlag::Disabled);
    }

    #[test]
    fn held_callback_flag_defaults_to_disabled_on_empty_vault() {
        use miden_client::testing::account_id::ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1;

        let flag = held_callback_flag([], faucet(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1));
        assert_eq!(flag, AssetCallbackFlag::Disabled);
    }
}