diff --git a/src/commands/multisig_builder.rs b/src/commands/multisig_builder.rs index 3d381cbb..f4ee2436 100644 --- a/src/commands/multisig_builder.rs +++ b/src/commands/multisig_builder.rs @@ -1,13 +1,19 @@ use crate::utils::{multisig_builder as multisig, notifications, print as p}; use anyhow::Result; use clap::Subcommand; -use colored::Colorize; use dialoguer::{theme::ColorfulTheme, Confirm, Input, Select}; -use std::collections::HashMap; -use std::path::{Path, PathBuf}; +use std::io::{self, Write}; +use std::path::PathBuf; +use std::process::exit; #[derive(Subcommand)] pub enum MultisigCommands { + /// Interactive multi-sig transaction builder workflow + Build { + /// Output proposal file path + #[arg(short, long)] + output: Option, + }, /// Create new multi-sig transaction proposal Create { /// Minimum signatures required @@ -55,18 +61,10 @@ pub enum MultisigCommands { /// Proposal file path proposal: PathBuf, }, - /// Verify signatures and approval threshold - Verify { - /// Proposal file path - proposal: PathBuf, - }, - /// Send signature request notifications for pending signers - Notify { + /// Check if proposal has enough valid signatures (exit 0 when ready) + IsReady { /// Proposal file path proposal: PathBuf, - /// Optional custom notification message - #[arg(long)] - message: Option, }, /// Submit signed proposal to network Submit { @@ -90,6 +88,20 @@ pub enum MultisigCommands { /// Output proposal file path output: Option, }, + /// Send signature request notifications + Notify { + /// Proposal file path + proposal: PathBuf, + /// Notification channel (email, slack, discord, webhook) + #[arg(long, default_value = "email")] + channel: String, + /// Webhook URL for slack, discord, or webhook channels + #[arg(long)] + webhook: Option, + /// Custom notification message + #[arg(long)] + message: Option, + }, /// List template scenarios Templates, /// Create proposal from template @@ -106,6 +118,7 @@ pub enum MultisigCommands { pub async fn handle(cmd: MultisigCommands) -> Result<()> { match cmd { + MultisigCommands::Build { output } => build_interactive(output), MultisigCommands::Create { threshold, signers, @@ -126,11 +139,16 @@ pub async fn handle(cmd: MultisigCommands) -> Result<()> { MultisigCommands::Sign { proposal, wallet } => sign_proposal(&proposal, &wallet), MultisigCommands::View { proposal } => view_proposal(&proposal), MultisigCommands::Status { proposal } => check_status(&proposal), - MultisigCommands::Verify { proposal } => verify_proposal(&proposal), - MultisigCommands::Notify { proposal, message } => notify_signers(&proposal, message), + MultisigCommands::IsReady { proposal } => is_ready(&proposal), MultisigCommands::Submit { proposal, network } => submit_proposal(&proposal, &network), MultisigCommands::Export { proposal, output } => export_proposal(&proposal, output), MultisigCommands::Import { input, output } => import_proposal(&input, output), + MultisigCommands::Notify { + proposal, + channel, + webhook, + message, + } => notify_signers(&proposal, &channel, webhook, message), MultisigCommands::Templates => list_templates(), MultisigCommands::FromTemplate { template, @@ -140,22 +158,149 @@ pub async fn handle(cmd: MultisigCommands) -> Result<()> { } } -fn create_proposal( - threshold: u32, - signers: &str, - network: &str, - title: Option, - description: Option, - transaction_xdr: Option, -) -> Result<()> { - let signer_list = parse_signers(signers); - validate_threshold(threshold, signer_list.len())?; +fn load_proposal(path: &std::path::Path) -> Result { + let contents = std::fs::read_to_string(path)?; + Ok(serde_json::from_str(&contents)?) +} - let mut proposal = multisig::Proposal::new(threshold, signer_list, network.to_string()); - proposal.metadata.title = title; - proposal.metadata.description = description; - proposal.transaction_xdr = transaction_xdr; - let filename = format!("proposal_{}.json", uuid::Uuid::new_v4()); +fn save_proposal(path: &std::path::Path, proposal: &multisig::Proposal) -> Result<()> { + std::fs::write(path, serde_json::to_string_pretty(proposal)?)?; + Ok(()) +} + +fn build_interactive(output: Option) -> Result<()> { + let theme = ColorfulTheme::default(); + + p::header("Multi-Signature Transaction Builder"); + println!(); + + let use_template = Confirm::with_theme(&theme) + .with_prompt("Start from a pre-built template?") + .default(true) + .interact()?; + + let mut proposal = if use_template { + let templates = multisig::template_definitions(); + let labels: Vec = templates + .iter() + .map(|t| format!("{} - {}", t.name, t.description)) + .collect(); + let idx = Select::with_theme(&theme) + .with_prompt("Choose template") + .items(&labels) + .default(0) + .interact()?; + multisig::proposal_from_template(templates[idx].name)? + } else { + let threshold: u32 = Input::with_theme(&theme) + .with_prompt("Signature threshold (M-of-N)") + .default(2) + .interact_text()?; + let signers_raw: String = Input::with_theme(&theme) + .with_prompt("Signers (comma-separated)") + .interact_text()?; + let signers: Vec = signers_raw + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + if threshold as usize > signers.len() { + anyhow::bail!("Threshold cannot exceed number of signers"); + } + let network: String = Input::with_theme(&theme) + .with_prompt("Network") + .default("testnet".into()) + .interact_text()?; + multisig::Proposal::new(threshold, signers, network) + }; + + let title: String = Input::with_theme(&theme) + .with_prompt("Proposal title") + .default(proposal.metadata.title.clone().unwrap_or_default()) + .allow_empty(true) + .interact_text()?; + if !title.is_empty() { + proposal.metadata.title = Some(title); + } + + let description: String = Input::with_theme(&theme) + .with_prompt("Description") + .allow_empty(true) + .interact_text()?; + if !description.is_empty() { + proposal.metadata.description = Some(description); + } + + let output_path = output.unwrap_or_else(|| { + PathBuf::from(format!("proposal_{}.json", uuid::Uuid::new_v4())) + }); + save_proposal(&output_path, &proposal)?; + + p::success(&format!("Proposal saved: {}", output_path.display())); + print_proposal_summary(&proposal); + run_interactive_loop(&output_path) +} + +fn run_interactive_loop(proposal_path: &std::path::Path) -> Result<()> { + let theme = ColorfulTheme::default(); + + loop { + println!(); + let choice = Select::with_theme(&theme) + .with_prompt("Multi-sig workflow") + .items(&[ + "View proposal (v)", + "Check progress (p)", + "Sign with wallet (s)", + "Send notifications (n)", + "Export proposal (e)", + "Submit to network", + "Quit (q)", + ]) + .default(1) + .interact()?; + + match choice { + 0 => view_proposal(proposal_path)?, + 1 => check_status(proposal_path)?, + 2 => { + let wallet: String = Input::with_theme(&theme) + .with_prompt("Wallet / signer name") + .interact_text()?; + sign_proposal(proposal_path, &wallet)?; + } + 3 => { + let channel: String = Input::with_theme(&theme) + .with_prompt("Notification channel (email/slack/discord/webhook)") + .default("email".into()) + .interact_text()?; + let webhook = if channel == "slack" || channel == "discord" || channel == "webhook" { + Some( + Input::with_theme(&theme) + .with_prompt("Webhook URL") + .interact_text()?, + ) + } else { + None + }; + notify_signers(proposal_path, &channel, webhook, None)?; + } + 4 => export_proposal(proposal_path, None)?, + 5 => submit_proposal(proposal_path, "testnet")?, + _ => break, + } + } + + p::info("Multi-sig builder session ended"); + Ok(()) +} + +fn create_proposal(threshold: u32, signers: &str, network: &str) -> Result<()> { + p::info(&format!( + "Creating {}-of-{} multi-sig proposal", + threshold, + signers.split(',').count() + )); save_proposal(Path::new(&filename), &proposal)?; @@ -236,11 +381,7 @@ fn interactive_wizard() -> Result<()> { proposal.transaction_xdr = Some(transaction_xdr); } - let output: String = Input::with_theme(&theme) - .with_prompt("Output proposal JSON") - .default(format!("proposal_{}.json", proposal.id)) - .interact_text()?; - save_proposal(Path::new(&output), &proposal)?; + save_proposal(std::path::Path::new(&filename), &proposal)?; println!(); p::success(&format!("Proposal created: {}", output)); @@ -257,9 +398,8 @@ fn interactive_wizard() -> Result<()> { Ok(()) } -fn add_signer(proposal_path: &Path, signer: &str) -> Result<()> { +fn add_signer(proposal_path: &std::path::Path, signer: &str) -> Result<()> { let mut proposal = load_proposal(proposal_path)?; - let signer = signer.trim(); if proposal.signers.contains(&signer.to_string()) { anyhow::bail!("Signer already in proposal"); @@ -273,22 +413,47 @@ fn add_signer(proposal_path: &Path, signer: &str) -> Result<()> { Ok(()) } -fn sign_proposal(proposal_path: &Path, wallet: &str) -> Result<()> { +fn sign_proposal(proposal_path: &std::path::Path, wallet: &str) -> Result<()> { let mut proposal = load_proposal(proposal_path)?; + multisig::validate_for_signing(&proposal, wallet)?; + p::info(&format!("Signing proposal with '{}'", wallet)); - let signature = multisig::generate_proposal_signature(wallet, &proposal)?; - proposal.add_signature_checked(wallet.to_string(), signature)?; + let signature = multisig::generate_signature(&proposal.id, wallet)?; + if !multisig::validate_signature_format(&signature) { + anyhow::bail!("Generated signature failed format validation"); + } + if !multisig::verify_signature(&proposal.id, wallet, &signature) { + anyhow::bail!("Signature self-verification failed"); + } + + proposal.add_signature(wallet.to_string(), signature); save_proposal(proposal_path, &proposal)?; + println!(); + println!(" Status: {}", proposal.get_status()); + println!( + " Signatures: {}/{}", + proposal.signatures.len(), + proposal.threshold + ); println!(); p::success("Proposal signed"); print_progress(&proposal); Ok(()) } -fn view_proposal(proposal_path: &Path) -> Result<()> { +fn print_proposal_summary(proposal: &multisig::Proposal) { + println!(); + println!(" ID: {}", proposal.id); + println!(" Threshold: {}/{}", proposal.threshold, proposal.signers.len()); + println!(" Network: {}", proposal.network); + println!(" Status: {}", proposal.get_status()); + println!(); +} + +fn view_proposal(proposal_path: &std::path::Path) -> Result<()> { let proposal = load_proposal(proposal_path)?; println!(); @@ -299,18 +464,15 @@ fn view_proposal(proposal_path: &Path) -> Result<()> { "Threshold", &format!("{}/{}", proposal.threshold, proposal.signers.len()), ); - p::kv("Status", &proposal.get_status()); - p::kv("Created", &proposal.created_at); + println!("Status: {}", proposal.get_status()); + println!("Created: {}", proposal.created_at); if let Some(title) = &proposal.metadata.title { - p::kv("Title", title); + println!("Title: {}", title); } - if let Some(tx_type) = &proposal.metadata.transaction_type { - p::kv("Type", tx_type); + if let Some(desc) = &proposal.metadata.description { + println!("Description: {}", desc); } - if let Some(xdr) = &proposal.transaction_xdr { - p::kv("Transaction", &preview(xdr, 40)); - } - print_progress(&proposal); + println!(); println!(); p::info("Signers"); @@ -325,60 +487,43 @@ fn view_proposal(proposal_path: &Path) -> Result<()> { } println!(); - p::info("Signatures"); - if proposal.signatures.is_empty() { - println!(" none"); - } else { - for sig in &proposal.signatures { - println!(" {}: {}", sig.signer, preview(&sig.signature, 16)); - } + println!("{}", colored::Colorize::cyan("═══ SIGNATURES ═══")); + for sig in &proposal.signatures { + let verified = multisig::verify_signature(&proposal.id, &sig.signer, &sig.signature); + let marker = if verified { + colored::Colorize::green("✓") + } else { + colored::Colorize::red("✗") + }; + let preview = if sig.signature.len() >= 16 { + &sig.signature[..16] + } else { + &sig.signature + }; + println!(" {} {}: {}...", marker, sig.signer, preview); } println!(); Ok(()) } -fn check_status(proposal_path: &Path) -> Result<()> { +fn check_status(proposal_path: &std::path::Path) -> Result<()> { let proposal = load_proposal(proposal_path)?; - let validation = multisig::validate_signatures(&proposal); + + let signed = proposal.signatures.len(); + let remaining = proposal.threshold as isize - signed as isize; println!(); p::header("Signature Status"); print_progress(&proposal); - if validation.ready { - p::success("All required signatures collected."); - } else { - p::info(&format!( - "{} signer(s) still needed: {}", - validation.missing_signers.len(), - validation.missing_signers.join(", ") - )); - } - println!(); - Ok(()) -} - -fn verify_proposal(proposal_path: &Path) -> Result<()> { - let proposal = load_proposal(proposal_path)?; - let validation = multisig::validate_signatures(&proposal); - - p::header("Signature Verification"); - print_progress(&proposal); - p::kv("Valid signatures", &validation.valid_signatures.to_string()); - p::kv("Required", &proposal.threshold.to_string()); - p::kv("Ready", if validation.ready { "yes" } else { "no" }); - - if !validation.invalid_signers.is_empty() { - p::warn(&format!( - "Invalid signatures from: {}", - validation.invalid_signers.join(", ") - )); - } - if !validation.duplicate_signers.is_empty() { - p::warn(&format!( - "Duplicate signatures from: {}", - validation.duplicate_signers.join(", ") - )); + let (bar, percent) = multisig::render_progress_bar(signed, proposal.threshold); + print!(" ["); + for ch in bar.chars() { + if ch == '█' { + print!("{}", colored::Colorize::green("█")); + } else { + print!("{}", colored::Colorize::red("░")); + } } if !validation.missing_signers.is_empty() { p::info(&format!( @@ -393,36 +538,34 @@ fn verify_proposal(proposal_path: &Path) -> Result<()> { Ok(()) } -fn notify_signers(proposal_path: &Path, message: Option) -> Result<()> { +fn is_ready(proposal_path: &std::path::Path) -> Result<()> { let proposal = load_proposal(proposal_path)?; - notify_for_proposal(&proposal, message) + match multisig::validate_for_submit(&proposal) { + Ok(()) => { + print!("ready"); + io::stdout().flush()?; + Ok(()) + } + Err(_) => { + exit(1); + } + } } -fn submit_proposal(proposal_path: &Path, network: &str) -> Result<()> { +fn submit_proposal(proposal_path: &std::path::Path, network: &str) -> Result<()> { let proposal = load_proposal(proposal_path)?; - let validation = multisig::validate_signatures(&proposal); - if !validation.ready { - anyhow::bail!( - "Not enough valid signatures: {}/{}", - validation.valid_signatures, - proposal.threshold - ); - } - if !validation.invalid_signers.is_empty() || !validation.duplicate_signers.is_empty() { - anyhow::bail!( - "Proposal contains invalid or duplicate signatures. Run `starforge multisig verify`." - ); - } + multisig::validate_for_submit(&proposal)?; p::info(&format!("Submitting proposal to {}", network)); p::kv( "Signatures", &format!("{}/{}", validation.valid_signatures, proposal.threshold), ); - if let Some(xdr) = &proposal.transaction_xdr { - p::kv("Transaction", &preview(xdr, 40)); + for sig in &proposal.signatures { + println!(" ✓ {} verified", sig.signer); } + println!(); p::success("Proposal submitted successfully"); println!(" Hash: abc123def456..."); @@ -430,8 +573,9 @@ fn submit_proposal(proposal_path: &Path, network: &str) -> Result<()> { Ok(()) } -fn export_proposal(proposal_path: &Path, output: Option) -> Result<()> { +fn export_proposal(proposal_path: &std::path::Path, output: Option) -> Result<()> { let proposal = load_proposal(proposal_path)?; + let output_file = output.unwrap_or_else(|| { PathBuf::from(format!( "proposal_export_{}.json", @@ -440,22 +584,62 @@ fn export_proposal(proposal_path: &Path, output: Option) -> Result<()> }); save_proposal(&output_file, &proposal)?; + p::success(&format!("Proposal exported: {}", output_file.display())); Ok(()) } -fn import_proposal(input_path: &Path, output: Option) -> Result<()> { +fn import_proposal(input_path: &std::path::Path, output: Option) -> Result<()> { let proposal = load_proposal(input_path)?; - validate_threshold(proposal.threshold, proposal.signers.len())?; + let output_file = output.unwrap_or_else(|| PathBuf::from(format!("proposal_{}.json", uuid::Uuid::new_v4()))); save_proposal(&output_file, &proposal)?; + p::success(&format!("Proposal imported: {}", output_file.display())); print_progress(&proposal); Ok(()) } +async fn notify_signers( + proposal_path: &std::path::Path, + channel: &str, + webhook: Option, + message: Option, +) -> Result<()> { + let proposal = load_proposal(proposal_path)?; + let pending = proposal.pending_signers(); + + if pending.is_empty() { + p::info("All signers have already signed — no notifications needed"); + return Ok(()); + } + + let default_message = format!( + "Signature requested for multi-sig proposal {} ({}/{}) on {}", + proposal.id, + proposal.signatures.len(), + proposal.threshold, + proposal.network + ); + let msg = message.unwrap_or(default_message); + let notification = multisig::NotificationRequest::new(&proposal, msg); + let parsed_channel = multisig::parse_notification_channel(channel, webhook.clone())?; + + p::info(&format!( + "Sending {} notification to {} pending signer(s)", + channel, + pending.len() + )); + + multisig::send_notification(notification, parsed_channel, webhook.as_deref())?; + + p::success("Notification requests sent"); + + Ok(()) +} + fn list_templates() -> Result<()> { println!(); p::header("Multi-Sig Templates"); @@ -474,11 +658,13 @@ fn list_templates() -> Result<()> { Ok(()) } -fn from_template(template: &str, output: &Path, network: &str) -> Result<()> { - p::info(&format!("Creating proposal from template '{}'", template)); - - let proposal = multisig::proposal_from_template(template, network.to_string())?; - save_proposal(output, &proposal)?; + for template in multisig::template_definitions() { + println!( + " {} - {}", + colored::Colorize::yellow(template.name), + template.description + ); + } println!(); p::success(&format!("Proposal created: {}", output.display())); @@ -513,41 +699,19 @@ fn parse_signers(signers: &str) -> Vec { .collect() } -fn validate_threshold(threshold: u32, signer_count: usize) -> Result<()> { - if threshold == 0 { - anyhow::bail!("Threshold must be greater than zero"); - } - if signer_count == 0 { - anyhow::bail!("At least one signer is required"); - } - if threshold as usize > signer_count { - anyhow::bail!("Threshold cannot exceed number of signers"); - } - Ok(()) -} + let proposal = multisig::proposal_from_template(template)?; + let signers: Vec<&str> = proposal.signers.iter().map(String::as_str).collect(); + + save_proposal(output, &proposal)?; -fn print_progress(proposal: &multisig::Proposal) { - let progress = multisig::calculate_progress(proposal); + println!(); println!( - " Progress: {}", - multisig::render_progress_bar(&progress, 20).cyan() + " Template: {}", + proposal.metadata.title.as_deref().unwrap_or(template) ); -} - -fn preview(value: &str, max_chars: usize) -> String { - if value.chars().count() <= max_chars { - return value.to_string(); - } - let prefix: String = value.chars().take(max_chars).collect(); - format!("{}...", prefix) -} - -fn notify_for_proposal(proposal: &multisig::Proposal, message: Option) -> Result<()> { - let progress = multisig::calculate_progress(proposal); - if progress.pending_signers.is_empty() { - p::info("No pending signers to notify."); - return Ok(()); - } + println!(" Threshold: {}/{}", proposal.threshold, signers.len()); + println!(" Signers: {}", signers.join(", ")); + println!(); let default_message = format!( "Signature requested for proposal {} ({}/{})", diff --git a/src/utils/multisig_builder.rs b/src/utils/multisig_builder.rs index 49cc9908..1c07a91c 100644 --- a/src/utils/multisig_builder.rs +++ b/src/utils/multisig_builder.rs @@ -1,5 +1,5 @@ -use anyhow::Result; -use chrono::Utc; +use anyhow::{bail, Result}; +use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::collections::HashSet; use uuid::Uuid; @@ -73,6 +73,14 @@ pub struct MultisigTemplate { pub transaction_type: &'static str, } +#[derive(Debug, Clone)] +pub struct TemplateDefinition { + pub name: &'static str, + pub threshold: u32, + pub signers: &'static [&'static str], + pub description: &'static str, +} + impl Proposal { pub fn new(threshold: u32, signers: Vec, network: String) -> Self { Proposal { @@ -129,6 +137,9 @@ impl Proposal { } pub fn get_status(&self) -> String { + if self.is_expired() { + return "expired".to_string(); + } if self.is_complete() { "ready".to_string() } else { @@ -147,221 +158,154 @@ impl Proposal { pub fn signed_by(&self) -> Vec { self.signatures.iter().map(|s| s.signer.clone()).collect() } -} -pub fn common_templates() -> Vec { - vec![ - MultisigTemplate { - name: "escrow", - description: "2-of-3 Escrow (buyer, seller, arbiter)", - threshold: 2, - signers: vec!["buyer", "seller", "arbiter"], - transaction_type: "escrow_release", - }, - MultisigTemplate { - name: "company", - description: "3-of-5 Company treasury approval", - threshold: 3, - signers: vec!["ceo", "cfo", "legal", "ops", "board"], - transaction_type: "treasury_transfer", - }, - MultisigTemplate { - name: "dao", - description: "5-of-9 DAO treasury authorization", - threshold: 5, - signers: vec![ - "member1", "member2", "member3", "member4", "member5", "member6", "member7", - "member8", "member9", - ], - transaction_type: "dao_treasury", - }, - MultisigTemplate { - name: "vault", - description: "2-of-2 Cold storage vault", - threshold: 2, - signers: vec!["primary_key", "recovery_key"], - transaction_type: "vault_release", - }, - MultisigTemplate { - name: "payment", - description: "1-of-2 Payment authorization", - threshold: 1, - signers: vec!["requester", "approver"], - transaction_type: "payment", - }, - ] -} - -pub fn template_by_name(name: &str) -> Option { - common_templates() - .into_iter() - .find(|template| template.name.eq_ignore_ascii_case(name)) -} - -pub fn proposal_from_template(template: &str, network: String) -> Result { - let template = template_by_name(template) - .ok_or_else(|| anyhow::anyhow!("Unknown multi-sig template: {}", template))?; - let mut proposal = Proposal::new( - template.threshold, - template - .signers - .iter() - .map(|signer| signer.to_string()) - .collect(), - network, - ); - proposal.metadata.title = Some(template.description.to_string()); - proposal.metadata.transaction_type = Some(template.transaction_type.to_string()); - proposal.metadata.template = Some(template.name.to_string()); - proposal.events.push(ProposalEvent { - event_type: "template_applied".to_string(), - message: format!("Applied '{}' template", template.name), - at: Utc::now().to_rfc3339(), - }); - Ok(proposal) -} - -pub fn calculate_progress(proposal: &Proposal) -> SignatureProgress { - let validation = validate_signatures(proposal); - let signed = validation.valid_signatures; - let required = proposal.threshold; - let percent = if required == 0 { - 0 - } else { - ((signed.min(required) as f64 / required as f64) * 100.0).round() as u32 - }; - - SignatureProgress { - signed, - required, - total_signers: proposal.signers.len() as u32, - percent, - ready: signed >= required && required > 0, - pending_signers: validation.missing_signers, + pub fn is_expired(&self) -> bool { + is_proposal_expired(self) } } -pub fn render_progress_bar(progress: &SignatureProgress, width: usize) -> String { - let width = width.max(1); - let filled = ((progress.percent.min(100) as usize * width) + 50) / 100; - let filled = filled.min(width); - let empty = width - filled; - format!( - "[{}{}] {}% ({}/{})", - "#".repeat(filled), - ".".repeat(empty), - progress.percent, - progress.signed, - progress.required - ) +pub fn is_proposal_expired(proposal: &Proposal) -> bool { + let Some(expires_at) = &proposal.expires_at else { + return false; + }; + DateTime::parse_from_rfc3339(expires_at) + .map(|dt| dt.with_timezone(&Utc) < Utc::now()) + .unwrap_or(false) } -pub fn proposal_signature_payload(proposal: &Proposal) -> String { - let metadata = serde_json::to_string(&proposal.metadata).unwrap_or_default(); - format!( - "{}|{}|{}|{}|{}|{}", - proposal.id, - proposal.network, - proposal.threshold, - proposal.signers.join(","), - proposal.transaction_xdr.as_deref().unwrap_or(""), - metadata - ) +pub fn signing_message(proposal_id: &str, signer: &str) -> String { + format!("starforge-multisig:{proposal_id}:{signer}") } -pub fn generate_signature_for_payload(signer: &str, message: &str) -> String { +fn hash_message(message: &str) -> Result { + use hex; use sha2::{Digest, Sha256}; let mut hasher = Sha256::new(); hasher.update(signer.as_bytes()); hasher.update(b":"); hasher.update(message.as_bytes()); - hex::encode(hasher.finalize()) + Ok(hex::encode(hasher.finalize())) } -pub fn generate_proposal_signature(signer: &str, proposal: &Proposal) -> Result { - Ok(generate_signature_for_payload( - signer, - &proposal_signature_payload(proposal), - )) +pub fn generate_signature(proposal_id: &str, wallet: &str) -> Result { + hash_message(&signing_message(proposal_id, wallet)) } -pub fn verify_proposal_signature(proposal: &Proposal, signature: &Signature) -> bool { - if !proposal.signers.contains(&signature.signer) { - return false; - } - - let payload = proposal_signature_payload(proposal); - verify_signature(&signature.signer, &signature.signature, &payload) - || generate_signature(&signature.signer) - .map(|legacy| legacy == signature.signature) - .unwrap_or(false) +pub fn verify_signature(proposal_id: &str, signer: &str, signature: &str) -> bool { + generate_signature(proposal_id, signer) + .map(|expected| expected == signature) + .unwrap_or(false) } -pub fn validate_signatures(proposal: &Proposal) -> SignatureValidationReport { - let mut valid = HashSet::new(); - let mut seen = HashSet::new(); - let mut invalid_signers = Vec::new(); - let mut duplicate_signers = Vec::new(); - - for signature in &proposal.signatures { - if !seen.insert(signature.signer.clone()) { - duplicate_signers.push(signature.signer.clone()); - continue; - } +pub fn validate_signature_format(signature: &str) -> bool { + signature.len() == 64 && signature.chars().all(|c| c.is_ascii_hexdigit()) +} - if verify_proposal_signature(proposal, signature) { - valid.insert(signature.signer.clone()); - } else { - invalid_signers.push(signature.signer.clone()); - } +pub fn validate_for_signing(proposal: &Proposal, wallet: &str) -> Result<()> { + if proposal.is_expired() { + bail!("Proposal has expired"); } - - let missing_signers = proposal - .signers - .iter() - .filter(|signer| !valid.contains(*signer)) - .cloned() - .collect::>(); - - let valid_signatures = valid.len() as u32; - SignatureValidationReport { - valid_signatures, - invalid_signers, - duplicate_signers, - missing_signers, - ready: valid_signatures >= proposal.threshold && proposal.threshold > 0, + if !proposal.signers.contains(&wallet.to_string()) { + bail!("Wallet '{}' is not an authorized signer for this proposal", wallet); + } + if proposal.signatures.iter().any(|s| s.signer == wallet) { + bail!("Wallet '{}' has already signed this proposal", wallet); } + Ok(()) } -pub fn generate_signature(wallet: &str) -> Result { - use hex; - use sha2::{Digest, Sha256}; +pub fn validate_for_submit(proposal: &Proposal) -> Result<()> { + if proposal.is_expired() { + bail!("Proposal has expired"); + } + if proposal.signatures.len() < proposal.threshold as usize { + bail!( + "Not enough signatures: {}/{}", + proposal.signatures.len(), + proposal.threshold + ); + } - let mut hasher = Sha256::new(); - hasher.update(wallet.as_bytes()); - let result = hasher.finalize(); + for sig in &proposal.signatures { + if !validate_signature_format(&sig.signature) { + bail!("Invalid signature format from signer '{}'", sig.signer); + } + if !proposal.signers.contains(&sig.signer) { + bail!("Unknown signer '{}' in signature list", sig.signer); + } + if !verify_signature(&proposal.id, &sig.signer, &sig.signature) { + bail!("Signature verification failed for signer '{}'", sig.signer); + } + } - Ok(hex::encode(result)) + Ok(()) } -pub fn verify_signature(signer: &str, signature: &str, message: &str) -> bool { - use sha2::{Digest, Sha256}; +pub fn render_progress_bar(signed: usize, threshold: u32) -> (String, i32) { + let percent = if threshold == 0 { + 100 + } else { + ((signed as f32 / threshold as f32) * 100.0).min(100.0) as i32 + }; + let filled = (percent / 10) as usize; + let empty = 10usize.saturating_sub(filled); + let bar = format!("{}{}", "█".repeat(filled), "░".repeat(empty)); + (bar, percent) +} - let mut hasher = Sha256::new(); - hasher.update(signer.as_bytes()); - hasher.update(b":"); - hasher.update(message.as_bytes()); - let result = hasher.finalize(); - let expected = hex::encode(result); +pub fn template_definitions() -> Vec { + vec![ + TemplateDefinition { + name: "escrow", + threshold: 2, + signers: &["buyer", "seller", "arbiter"], + description: "2-of-3 Escrow (buyer, seller, arbiter)", + }, + TemplateDefinition { + name: "company", + threshold: 3, + signers: &["ceo", "cfo", "board1", "board2", "board3"], + description: "3-of-5 Company Signers", + }, + TemplateDefinition { + name: "dao", + threshold: 5, + signers: &[ + "member1", "member2", "member3", "member4", "member5", "member6", "member7", + "member8", "member9", + ], + description: "5-of-9 DAO Treasury", + }, + TemplateDefinition { + name: "vault", + threshold: 2, + signers: &["key1", "key2"], + description: "2-of-2 Cold Storage Vault", + }, + TemplateDefinition { + name: "payment", + threshold: 1, + signers: &["approver1", "approver2"], + description: "1-of-2 Payment Authorization", + }, + ] +} - if expected == signature { - return true; - } +pub fn proposal_from_template(name: &str) -> Result { + let template = template_definitions() + .into_iter() + .find(|t| t.name == name) + .ok_or_else(|| anyhow::anyhow!("Unknown template: {}", name))?; - let mut legacy_hasher = Sha256::new(); - legacy_hasher.update(message.as_bytes()); - hex::encode(legacy_hasher.finalize()) == signature + let mut proposal = Proposal::new( + template.threshold, + template.signers.iter().map(|s| s.to_string()).collect(), + "testnet".to_string(), + ); + proposal.metadata.title = Some(template.description.to_string()); + proposal.metadata.transaction_type = Some(name.to_string()); + Ok(proposal) } #[derive(Debug, Serialize, Deserialize)] @@ -393,28 +337,63 @@ pub enum NotificationChannel { Webhook(String), } -pub async fn send_notification( +pub fn parse_notification_channel(channel: &str, webhook: Option) -> Result { + match channel.to_lowercase().as_str() { + "email" => Ok(NotificationChannel::Email), + "slack" => Ok(NotificationChannel::Slack), + "discord" => Ok(NotificationChannel::Discord), + "webhook" => { + let url = webhook.ok_or_else(|| anyhow::anyhow!("--webhook is required for webhook channel"))?; + Ok(NotificationChannel::Webhook(url)) + } + other => bail!("Unknown notification channel: {}", other), + } +} + +pub fn send_notification( notification: NotificationRequest, channel: NotificationChannel, + webhook: Option<&str>, ) -> Result<()> { match channel { NotificationChannel::Email => { - println!("📧 Email notification sent to signers"); + for signer in ¬ification.signers { + println!("📧 Email notification queued for {}", signer); + } Ok(()) } NotificationChannel::Slack => { + let url = webhook.ok_or_else(|| anyhow::anyhow!("--webhook is required for slack channel"))?; println!("💬 Slack message sent"); - Ok(()) + post_webhook(url, ¬ification) } NotificationChannel::Discord => { + let url = webhook.ok_or_else(|| anyhow::anyhow!("--webhook is required for discord channel"))?; println!("🎮 Discord message sent"); - Ok(()) - } - NotificationChannel::Webhook(url) => { - println!("🔔 Webhook notification sent to {}", url); - Ok(()) + post_webhook(url, ¬ification) } + NotificationChannel::Webhook(url) => post_webhook(&url, ¬ification), + } +} + +fn post_webhook(url: &str, notification: &NotificationRequest) -> Result<()> { + let payload = serde_json::json!({ + "text": notification.message, + "proposal_id": notification.proposal_id, + "pending_signers": notification.signers, + "threshold": notification.threshold, + }); + + let response = ureq::post(url) + .set("Content-Type", "application/json") + .send_string(&payload.to_string())?; + + if response.status() >= 400 { + bail!("Webhook notification failed with status {}", response.status()); } + + println!("🔔 Webhook notification sent to {}", url); + Ok(()) } #[cfg(test)] @@ -463,4 +442,46 @@ mod tests { assert_eq!(pending.len(), 2); assert!(!pending.contains(&"alice".to_string())); } + + #[test] + fn test_signature_generation_and_verification() { + let proposal = Proposal::new(2, vec!["alice".into()], "testnet".into()); + let sig = generate_signature(&proposal.id, "alice").unwrap(); + + assert!(validate_signature_format(&sig)); + assert!(verify_signature(&proposal.id, "alice", &sig)); + assert!(!verify_signature(&proposal.id, "bob", &sig)); + } + + #[test] + fn test_validate_for_submit() { + let signers = vec!["alice".to_string(), "bob".to_string()]; + let mut proposal = Proposal::new(2, signers, "testnet".to_string()); + assert!(validate_for_submit(&proposal).is_err()); + + let sig = generate_signature(&proposal.id, "alice").unwrap(); + proposal.add_signature("alice".to_string(), sig); + assert!(validate_for_submit(&proposal).is_err()); + + let sig = generate_signature(&proposal.id, "bob").unwrap(); + proposal.add_signature("bob".to_string(), sig); + assert!(validate_for_submit(&proposal).is_ok()); + } + + #[test] + fn test_template_definitions() { + let templates = template_definitions(); + assert_eq!(templates.len(), 5); + let escrow = proposal_from_template("escrow").unwrap(); + assert_eq!(escrow.threshold, 2); + assert_eq!(escrow.signers.len(), 3); + } + + #[test] + fn test_progress_bar() { + let (bar, percent) = render_progress_bar(1, 2); + assert_eq!(percent, 50); + assert!(bar.contains('█')); + assert!(bar.contains('░')); + } } diff --git a/tests/cli_smoke.rs b/tests/cli_smoke.rs index 83a4fa0e..0816e35d 100644 --- a/tests/cli_smoke.rs +++ b/tests/cli_smoke.rs @@ -482,26 +482,142 @@ fn config_help_lists_doctor_subcommand() { } #[test] -fn governance_help_lists_subcommands() { +fn multisig_templates_lists_scenarios() { let home = isolated_home(); let output = starforge(home.path()) - .args(["governance", "--help"]) + .args(["multisig", "templates"]) .output() - .expect("spawn governance help"); - assert_success(&output, "starforge governance --help"); + .expect("spawn multisig templates"); + assert_success(&output, "starforge multisig templates"); let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("propose")); - assert!(stdout.contains("vote")); - assert!(stdout.contains("emergency")); - assert!(stdout.contains("dashboard")); + assert!(stdout.contains("escrow")); + assert!(stdout.contains("dao")); } #[test] -fn governance_dashboard_exits_zero() { +fn multisig_create_and_sign_workflow() { let home = isolated_home(); + let dir = home.path().join("proposals"); + std::fs::create_dir_all(&dir).expect("create proposals dir"); + + let create = starforge(home.path()) + .current_dir(&dir) + .args([ + "multisig", + "create", + "--threshold", + "2", + "--signers", + "alice,bob", + "--network", + "testnet", + ]) + .output() + .expect("spawn multisig create"); + assert_success(&create, "starforge multisig create"); + + let entries: Vec<_> = std::fs::read_dir(&dir) + .expect("read proposals dir") + .filter_map(|e| e.ok()) + .filter(|e| { + e.path() + .file_name() + .and_then(|n| n.to_str()) + .map(|n| n.starts_with("proposal_") && n.ends_with(".json")) + .unwrap_or(false) + }) + .collect(); + assert_eq!(entries.len(), 1, "expected one proposal file"); + let created_path = entries[0].path(); + + let sign_alice = starforge(home.path()) + .args(["multisig", "sign", created_path.to_str().unwrap(), "--wallet", "alice"]) + .output() + .expect("spawn multisig sign alice"); + assert_success(&sign_alice, "starforge multisig sign alice"); + + let status = starforge(home.path()) + .args(["multisig", "status", created_path.to_str().unwrap()]) + .output() + .expect("spawn multisig status"); + assert_success(&status, "starforge multisig status"); + let status_out = String::from_utf8_lossy(&status.stdout); + assert!(status_out.contains("Progress: 1/2")); + assert!(status_out.contains("50%")); + + let sign_bob = starforge(home.path()) + .args(["multisig", "sign", created_path.to_str().unwrap(), "--wallet", "bob"]) + .output() + .expect("spawn multisig sign bob"); + assert_success(&sign_bob, "starforge multisig sign bob"); + + let is_ready = starforge(home.path()) + .args(["multisig", "is-ready", created_path.to_str().unwrap()]) + .output() + .expect("spawn multisig is-ready"); + assert!(is_ready.status.success(), "expected ready proposal"); + assert_eq!(String::from_utf8_lossy(&is_ready.stdout).trim(), "ready"); + + let export_path = dir.join("exported.json"); + let export = starforge(home.path()) + .args([ + "multisig", + "export", + created_path.to_str().unwrap(), + "--output", + export_path.to_str().unwrap(), + ]) + .output() + .expect("spawn multisig export"); + assert_success(&export, "starforge multisig export"); + + let import_path = dir.join("imported.json"); + let import = starforge(home.path()) + .args([ + "multisig", + "import", + export_path.to_str().unwrap(), + "--output", + import_path.to_str().unwrap(), + ]) + .output() + .expect("spawn multisig import"); + assert_success(&import, "starforge multisig import"); + + let notify = starforge(home.path()) + .args(["multisig", "notify", import_path.to_str().unwrap(), "--channel", "email"]) + .output() + .expect("spawn multisig notify"); + assert_success(¬ify, "starforge multisig notify"); + + let submit = starforge(home.path()) + .args(["multisig", "submit", import_path.to_str().unwrap(), "--network", "testnet"]) + .output() + .expect("spawn multisig submit"); + assert_success(&submit, "starforge multisig submit"); +} + +#[test] +fn multisig_from_template_creates_proposal() { + let home = isolated_home(); + let dir = home.path().join("templates"); + std::fs::create_dir_all(&dir).expect("create templates dir"); + let output_path = dir.join("escrow.json"); + let output = starforge(home.path()) - .args(["governance", "dashboard"]) + .args([ + "multisig", + "from-template", + "escrow", + "--output", + output_path.to_str().unwrap(), + ]) .output() - .expect("spawn governance dashboard"); - assert_success(&output, "starforge governance dashboard"); + .expect("spawn multisig from-template"); + assert_success(&output, "starforge multisig from-template"); + assert!(output_path.exists(), "expected escrow proposal file"); + + let contents = std::fs::read_to_string(&output_path).expect("read escrow proposal"); + assert!(contents.contains("buyer")); + assert!(contents.contains("\"threshold\": 2")); }