miden-multisig-client 0.12.7

High-level SDK for interacting with multisig accounts on Miden via PSM
Documentation
//! Configuration types for the multisig client.

use miden_objects::Word;
use miden_objects::account::AccountStorageMode;
use private_state_manager_shared::SignatureScheme;

use crate::procedures::ProcedureName;

#[derive(Debug, Clone)]
pub struct PsmConfig {
    /// The gRPC endpoint URL
    pub endpoint: String,
}

impl PsmConfig {
    pub fn new(endpoint: impl Into<String>) -> Self {
        Self {
            endpoint: endpoint.into(),
        }
    }
}

/// Per-procedure threshold override.
///
/// Allows specifying different signature thresholds for specific procedures.
///
/// # Example
///
/// ```
/// use miden_multisig_client::{ProcedureThreshold, ProcedureName};
///
/// // Require only 1 signature for receiving assets
/// let receive_threshold = ProcedureThreshold::new(ProcedureName::ReceiveAsset, 1);
///
/// // Require all signers for configuration changes
/// let config_threshold = ProcedureThreshold::new(ProcedureName::UpdateSigners, 3);
/// ```
#[derive(Debug, Clone, Copy)]
pub struct ProcedureThreshold {
    /// The procedure name
    pub procedure: ProcedureName,
    /// Required signatures for this procedure
    pub threshold: u32,
}

impl ProcedureThreshold {
    /// Creates a new procedure threshold override.
    pub fn new(procedure: ProcedureName, threshold: u32) -> Self {
        Self {
            procedure,
            threshold,
        }
    }

    /// Returns the procedure root (MAST root hash) for this threshold.
    pub fn procedure_root(&self, scheme: SignatureScheme) -> Word {
        self.procedure.root_for_scheme(scheme)
    }
}

/// Configuration for creating a new multisig account.
#[derive(Debug, Clone)]
pub struct MultisigConfig {
    pub threshold: u32,
    pub signer_commitments: Vec<Word>,
    pub psm_config: PsmConfig,
    pub signature_scheme: SignatureScheme,
    pub storage_mode: AccountStorageMode,
    pub procedure_thresholds: Vec<ProcedureThreshold>,
}

impl MultisigConfig {
    /// Creates a new multisig configuration.
    ///
    /// # Arguments
    /// * `threshold` - Minimum number of signatures required
    /// * `signer_commitments` - Public key commitments of all signers
    /// * `psm_config` - PSM server configuration
    pub fn new(threshold: u32, signer_commitments: Vec<Word>, psm_config: PsmConfig) -> Self {
        Self {
            threshold,
            signer_commitments,
            psm_config,
            signature_scheme: SignatureScheme::Falcon,
            storage_mode: AccountStorageMode::Private,
            procedure_thresholds: Vec::new(),
        }
    }

    /// Sets the account storage mode.
    pub fn with_storage_mode(mut self, storage_mode: AccountStorageMode) -> Self {
        self.storage_mode = storage_mode;
        self
    }

    /// Sets the signature scheme for the multisig account.
    pub fn with_signature_scheme(mut self, signature_scheme: SignatureScheme) -> Self {
        self.signature_scheme = signature_scheme;
        self
    }

    /// Sets per-procedure threshold overrides.
    pub fn with_procedure_thresholds(mut self, thresholds: Vec<ProcedureThreshold>) -> Self {
        self.procedure_thresholds = thresholds;
        self
    }

    /// Validates the configuration.
    pub fn validate(&self) -> Result<(), String> {
        if self.threshold == 0 {
            return Err("threshold must be greater than 0".to_string());
        }
        if self.signer_commitments.is_empty() {
            return Err("at least one signer commitment is required".to_string());
        }
        if self.threshold > self.signer_commitments.len() as u32 {
            return Err(format!(
                "threshold ({}) cannot exceed number of signers ({})",
                self.threshold,
                self.signer_commitments.len()
            ));
        }
        if self.psm_config.endpoint.is_empty() {
            return Err("PSM endpoint is required".to_string());
        }

        for pt in &self.procedure_thresholds {
            if pt.threshold == 0 {
                return Err("procedure threshold must be at least 1".to_string());
            }
            if pt.threshold > self.signer_commitments.len() as u32 {
                return Err(format!(
                    "procedure threshold ({}) cannot exceed number of signers ({})",
                    pt.threshold,
                    self.signer_commitments.len()
                ));
            }
        }

        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use miden_objects::Word;
    use miden_objects::account::AccountStorageMode;

    fn dummy_word() -> Word {
        Word::default()
    }

    #[test]
    fn psm_config_new_creates_with_endpoint() {
        let config = PsmConfig::new("http://localhost:50051");
        assert_eq!(config.endpoint, "http://localhost:50051");
    }

    #[test]
    fn psm_config_new_accepts_string() {
        let endpoint = String::from("http://localhost:50051");
        let config = PsmConfig::new(endpoint);
        assert_eq!(config.endpoint, "http://localhost:50051");
    }

    #[test]
    fn procedure_threshold_new_creates_correctly() {
        let threshold = ProcedureThreshold::new(ProcedureName::ReceiveAsset, 1);
        assert_eq!(threshold.procedure, ProcedureName::ReceiveAsset);
        assert_eq!(threshold.threshold, 1);
    }

    #[test]
    fn procedure_threshold_procedure_root_returns_correct_root() {
        let threshold = ProcedureThreshold::new(ProcedureName::SendAsset, 2);
        let root = threshold.procedure_root(SignatureScheme::Falcon);
        // The root should match the procedure's root for Falcon
        assert_eq!(
            root,
            ProcedureName::SendAsset.root_for_scheme(SignatureScheme::Falcon)
        );
    }

    #[test]
    fn multisig_config_new_sets_all_fields() {
        let signers = vec![dummy_word(), dummy_word()];
        let psm = PsmConfig::new("http://psm:50051");
        let config = MultisigConfig::new(2, signers.clone(), psm);

        assert_eq!(config.threshold, 2);
        assert_eq!(config.signer_commitments.len(), 2);
        assert_eq!(config.psm_config.endpoint, "http://psm:50051");
        // Verify defaults
        assert!(matches!(config.storage_mode, AccountStorageMode::Private));
        assert!(matches!(config.signature_scheme, SignatureScheme::Falcon));
        assert!(config.procedure_thresholds.is_empty());
    }

    #[test]
    fn multisig_config_with_storage_mode_sets_mode() {
        let config = MultisigConfig::new(1, vec![dummy_word()], PsmConfig::new("http://psm:50051"))
            .with_storage_mode(AccountStorageMode::Public);
        assert!(matches!(config.storage_mode, AccountStorageMode::Public));
    }

    #[test]
    fn multisig_config_with_procedure_thresholds_sets_thresholds() {
        let thresholds = vec![
            ProcedureThreshold::new(ProcedureName::ReceiveAsset, 1),
            ProcedureThreshold::new(ProcedureName::SendAsset, 2),
        ];
        let config = MultisigConfig::new(
            2,
            vec![dummy_word(), dummy_word()],
            PsmConfig::new("http://psm:50051"),
        )
        .with_procedure_thresholds(thresholds);

        assert_eq!(config.procedure_thresholds.len(), 2);
        assert_eq!(
            config.procedure_thresholds[0].procedure,
            ProcedureName::ReceiveAsset
        );
        assert_eq!(config.procedure_thresholds[0].threshold, 1);
        assert_eq!(
            config.procedure_thresholds[1].procedure,
            ProcedureName::SendAsset
        );
        assert_eq!(config.procedure_thresholds[1].threshold, 2);
    }

    #[test]
    fn validate_threshold_zero_returns_error() {
        let config = MultisigConfig::new(0, vec![dummy_word()], PsmConfig::new("http://psm:50051"));
        let err = config.validate().unwrap_err();
        assert!(err.contains("threshold"));
        assert!(err.contains("greater than 0"));
    }

    #[test]
    fn validate_empty_commitments_returns_error() {
        let config = MultisigConfig::new(1, vec![], PsmConfig::new("http://psm:50051"));
        let err = config.validate().unwrap_err();
        assert!(err.contains("signer"));
    }

    #[test]
    fn validate_threshold_exceeds_signers_returns_error() {
        let config = MultisigConfig::new(
            3,
            vec![dummy_word(), dummy_word()],
            PsmConfig::new("http://psm:50051"),
        );
        let err = config.validate().unwrap_err();
        assert!(err.contains("exceed"));
        assert!(err.contains("3"));
        assert!(err.contains("2"));
    }

    #[test]
    fn validate_empty_psm_endpoint_returns_error() {
        let config = MultisigConfig::new(1, vec![dummy_word()], PsmConfig::new(""));
        let err = config.validate().unwrap_err();
        assert!(err.contains("PSM endpoint"));
    }

    #[test]
    fn validate_valid_1_of_1_config() {
        let config = MultisigConfig::new(1, vec![dummy_word()], PsmConfig::new("http://psm:50051"));
        assert!(config.validate().is_ok());
    }

    #[test]
    fn validate_valid_2_of_3_config() {
        let config = MultisigConfig::new(
            2,
            vec![dummy_word(), dummy_word(), dummy_word()],
            PsmConfig::new("http://psm:50051"),
        );
        assert!(config.validate().is_ok());
    }

    #[test]
    fn validate_valid_3_of_3_config() {
        let config = MultisigConfig::new(
            3,
            vec![dummy_word(), dummy_word(), dummy_word()],
            PsmConfig::new("http://psm:50051"),
        );
        assert!(config.validate().is_ok());
    }

    #[test]
    fn validate_threshold_equals_signers_is_valid() {
        let config = MultisigConfig::new(
            2,
            vec![dummy_word(), dummy_word()],
            PsmConfig::new("http://psm:50051"),
        );
        assert!(config.validate().is_ok());
    }

    #[test]
    fn validate_procedure_threshold_zero_returns_error() {
        let thresholds = vec![ProcedureThreshold::new(ProcedureName::ReceiveAsset, 0)];
        let config = MultisigConfig::new(1, vec![dummy_word()], PsmConfig::new("http://psm:50051"))
            .with_procedure_thresholds(thresholds);

        let err = config.validate().unwrap_err();
        assert!(err.contains("procedure threshold"));
        assert!(err.contains("at least 1"));
    }

    #[test]
    fn validate_procedure_threshold_exceeds_signers_returns_error() {
        let thresholds = vec![ProcedureThreshold::new(ProcedureName::SendAsset, 5)];
        let config = MultisigConfig::new(
            2,
            vec![dummy_word(), dummy_word(), dummy_word()],
            PsmConfig::new("http://psm:50051"),
        )
        .with_procedure_thresholds(thresholds);

        let err = config.validate().unwrap_err();
        assert!(err.contains("procedure threshold"));
        assert!(err.contains("5"));
        assert!(err.contains("3"));
    }

    #[test]
    fn validate_valid_config_with_procedure_thresholds() {
        let thresholds = vec![
            ProcedureThreshold::new(ProcedureName::ReceiveAsset, 1),
            ProcedureThreshold::new(ProcedureName::UpdateSigners, 3),
        ];
        let config = MultisigConfig::new(
            2,
            vec![dummy_word(), dummy_word(), dummy_word()],
            PsmConfig::new("http://psm:50051"),
        )
        .with_procedure_thresholds(thresholds);

        assert!(config.validate().is_ok());
    }

    #[test]
    fn validate_with_storage_mode_and_thresholds() {
        let thresholds = vec![ProcedureThreshold::new(ProcedureName::ReceiveAsset, 1)];
        let config = MultisigConfig::new(
            2,
            vec![dummy_word(), dummy_word()],
            PsmConfig::new("http://psm:50051"),
        )
        .with_storage_mode(AccountStorageMode::Public)
        .with_procedure_thresholds(thresholds);

        assert!(config.validate().is_ok());
        assert!(matches!(config.storage_mode, AccountStorageMode::Public));
        assert_eq!(config.procedure_thresholds.len(), 1);
    }
}