From 22cb8504e530843035f42b75bf183b0eb4bd66e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Ccaneryy=E2=80=9D?= Date: Tue, 30 Jun 2026 10:37:27 +0300 Subject: [PATCH] feat(multisig): add interactive builder, notifications, and signature verification --- src/commands/multisig_builder.rs | 380 ++++++++++++++++++++++++------- src/utils/multisig_builder.rs | 255 +++++++++++++++++++-- tests/cli_smoke.rs | 141 ++++++++++++ 3 files changed, 675 insertions(+), 101 deletions(-) diff --git a/src/commands/multisig_builder.rs b/src/commands/multisig_builder.rs index 15b73971..bfe15d8b 100644 --- a/src/commands/multisig_builder.rs +++ b/src/commands/multisig_builder.rs @@ -1,10 +1,19 @@ use crate::utils::{multisig_builder as multisig, print as p}; use anyhow::Result; use clap::Subcommand; +use dialoguer::{theme::ColorfulTheme, Confirm, Input, Select}; +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 @@ -41,6 +50,11 @@ pub enum MultisigCommands { /// Proposal file path proposal: PathBuf, }, + /// Check if proposal has enough valid signatures (exit 0 when ready) + IsReady { + /// Proposal file path + proposal: PathBuf, + }, /// Submit signed proposal to network Submit { /// Proposal file path @@ -63,6 +77,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 @@ -76,6 +104,7 @@ pub enum MultisigCommands { pub async fn handle(cmd: MultisigCommands) -> Result<()> { match cmd { + MultisigCommands::Build { output } => build_interactive(output), MultisigCommands::Create { threshold, signers, @@ -85,14 +114,158 @@ 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::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, output } => from_template(&template, &output), } } +fn load_proposal(path: &std::path::Path) -> Result { + let contents = std::fs::read_to_string(path)?; + Ok(serde_json::from_str(&contents)?) +} + +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", @@ -109,7 +282,7 @@ fn create_proposal(threshold: u32, signers: &str, network: &str) -> Result<()> { let proposal = multisig::Proposal::new(threshold, signer_list, network.to_string()); let filename = format!("proposal_{}.json", uuid::Uuid::new_v4()); - std::fs::write(&filename, serde_json::to_string_pretty(&proposal)?)?; + save_proposal(std::path::Path::new(&filename), &proposal)?; println!(); println!(" Proposal: {}", colored::Colorize::cyan(filename.as_str())); @@ -123,15 +296,14 @@ fn create_proposal(threshold: u32, signers: &str, network: &str) -> Result<()> { } fn add_signer(proposal_path: &std::path::Path, signer: &str) -> Result<()> { - let contents = std::fs::read_to_string(proposal_path)?; - let mut proposal: multisig::Proposal = serde_json::from_str(&contents)?; + let mut proposal = load_proposal(proposal_path)?; if proposal.signers.contains(&signer.to_string()) { anyhow::bail!("Signer already in proposal"); } proposal.signers.push(signer.to_string()); - std::fs::write(proposal_path, serde_json::to_string_pretty(&proposal)?)?; + save_proposal(proposal_path, &proposal)?; p::success(&format!("Signer added: {}", signer)); @@ -139,15 +311,22 @@ fn add_signer(proposal_path: &std::path::Path, signer: &str) -> Result<()> { } fn sign_proposal(proposal_path: &std::path::Path, wallet: &str) -> Result<()> { - let contents = std::fs::read_to_string(proposal_path)?; - let mut proposal: multisig::Proposal = serde_json::from_str(&contents)?; + let mut proposal = load_proposal(proposal_path)?; + + multisig::validate_for_signing(&proposal, wallet)?; p::info(&format!("Signing proposal with wallet '{}'", wallet)); - let signature = multisig::generate_signature(wallet)?; - proposal.add_signature(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"); + } - std::fs::write(proposal_path, serde_json::to_string_pretty(&proposal)?)?; + proposal.add_signature(wallet.to_string(), signature); + save_proposal(proposal_path, &proposal)?; println!(); println!(" Status: {}", proposal.get_status()); @@ -163,9 +342,17 @@ fn sign_proposal(proposal_path: &std::path::Path, wallet: &str) -> Result<()> { Ok(()) } +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 contents = std::fs::read_to_string(proposal_path)?; - let proposal: multisig::Proposal = serde_json::from_str(&contents)?; + let proposal = load_proposal(proposal_path)?; println!(); println!("{}", colored::Colorize::cyan("═══ PROPOSAL ═══")); @@ -178,6 +365,12 @@ fn view_proposal(proposal_path: &std::path::Path) -> Result<()> { ); println!("Status: {}", proposal.get_status()); println!("Created: {}", proposal.created_at); + if let Some(title) = &proposal.metadata.title { + println!("Title: {}", title); + } + if let Some(desc) = &proposal.metadata.description { + println!("Description: {}", desc); + } println!(); println!("{}", colored::Colorize::cyan("═══ SIGNERS ═══")); @@ -194,7 +387,18 @@ fn view_proposal(proposal_path: &std::path::Path) -> Result<()> { println!(); println!("{}", colored::Colorize::cyan("═══ SIGNATURES ═══")); for sig in &proposal.signatures { - println!(" ✓ {}: {}", sig.signer, &sig.signature[..16]); + 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!(); @@ -202,27 +406,23 @@ fn view_proposal(proposal_path: &std::path::Path) -> Result<()> { } fn check_status(proposal_path: &std::path::Path) -> Result<()> { - let contents = std::fs::read_to_string(proposal_path)?; - let proposal: multisig::Proposal = serde_json::from_str(&contents)?; + let proposal = load_proposal(proposal_path)?; - let total = proposal.signers.len(); let signed = proposal.signatures.len(); - let remaining = proposal.threshold as usize - signed; + let remaining = proposal.threshold as isize - signed as isize; println!(); println!("{}", colored::Colorize::cyan("═══ SIGNATURE STATUS ═══")); println!("Progress: {}/{}", signed, proposal.threshold); - let percent = (signed as f32 / proposal.threshold as f32 * 100.0) as i32; - let filled = (percent / 10) as usize; - let empty = 10 - filled; - + let (bar, percent) = multisig::render_progress_bar(signed, proposal.threshold); print!(" ["); - for _ in 0..filled { - print!("{}", colored::Colorize::green("█")); - } - for _ in 0..empty { - print!("{}", colored::Colorize::red("░")); + for ch in bar.chars() { + if ch == '█' { + print!("{}", colored::Colorize::green("█")); + } else { + print!("{}", colored::Colorize::red("░")); + } } println!("] {}%", percent); @@ -246,17 +446,24 @@ fn check_status(proposal_path: &std::path::Path) -> Result<()> { Ok(()) } -fn submit_proposal(proposal_path: &std::path::Path, network: &str) -> Result<()> { - let contents = std::fs::read_to_string(proposal_path)?; - let proposal: multisig::Proposal = serde_json::from_str(&contents)?; - - if proposal.signatures.len() < proposal.threshold as usize { - anyhow::bail!( - "Not enough signatures: {}/{}", - proposal.signatures.len(), - proposal.threshold - ); +fn is_ready(proposal_path: &std::path::Path) -> Result<()> { + let proposal = load_proposal(proposal_path)?; + match multisig::validate_for_submit(&proposal) { + Ok(()) => { + print!("ready"); + io::stdout().flush()?; + Ok(()) + } + Err(_) => { + exit(1); + } } +} + +fn submit_proposal(proposal_path: &std::path::Path, network: &str) -> Result<()> { + let proposal = load_proposal(proposal_path)?; + + multisig::validate_for_submit(&proposal)?; p::info(&format!("Submitting proposal to {}", network)); println!( @@ -264,6 +471,9 @@ fn submit_proposal(proposal_path: &std::path::Path, network: &str) -> Result<()> proposal.signatures.len(), proposal.threshold ); + for sig in &proposal.signatures { + println!(" ✓ {} verified", sig.signer); + } println!(); p::success("Proposal submitted successfully"); @@ -274,8 +484,7 @@ fn submit_proposal(proposal_path: &std::path::Path, network: &str) -> Result<()> } fn export_proposal(proposal_path: &std::path::Path, output: Option) -> Result<()> { - let contents = std::fs::read_to_string(proposal_path)?; - let proposal: multisig::Proposal = serde_json::from_str(&contents)?; + let proposal = load_proposal(proposal_path)?; let output_file = output.unwrap_or_else(|| { PathBuf::from(format!( @@ -284,7 +493,7 @@ fn export_proposal(proposal_path: &std::path::Path, output: Option) -> )) }); - std::fs::write(&output_file, serde_json::to_string_pretty(&proposal)?)?; + save_proposal(&output_file, &proposal)?; p::success(&format!("Proposal exported: {}", output_file.display())); @@ -292,34 +501,67 @@ fn export_proposal(proposal_path: &std::path::Path, output: Option) -> } fn import_proposal(input_path: &std::path::Path, output: Option) -> Result<()> { - let contents = std::fs::read_to_string(input_path)?; - let proposal: multisig::Proposal = serde_json::from_str(&contents)?; + let proposal = load_proposal(input_path)?; let output_file = output.unwrap_or_else(|| PathBuf::from(format!("proposal_{}.json", uuid::Uuid::new_v4()))); - std::fs::write(&output_file, serde_json::to_string_pretty(&proposal)?)?; + save_proposal(&output_file, &proposal)?; p::success(&format!("Proposal imported: {}", output_file.display())); 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!(); println!("{}", colored::Colorize::cyan("═══ MULTI-SIG TEMPLATES ═══")); println!(); - let templates = vec![ - ("escrow", "2-of-3 Escrow (buyer, seller, arbiter)"), - ("company", "3-of-5 Company Signers"), - ("dao", "5-of-9 DAO Treasury"), - ("vault", "2-of-2 Cold Storage Vault"), - ("payment", "1-of-2 Payment Authorization"), - ]; - - for (name, desc) in templates { - println!(" {} - {}", colored::Colorize::yellow(name), desc); + for template in multisig::template_definitions() { + println!( + " {} - {}", + colored::Colorize::yellow(template.name), + template.description + ); } println!(); @@ -332,37 +574,17 @@ fn list_templates() -> Result<()> { fn from_template(template: &str, output: &std::path::Path) -> Result<()> { p::info(&format!("Creating proposal from template '{}'", template)); - let (threshold, signers, name) = match template { - "escrow" => (2, vec!["buyer", "seller", "arbiter"], "2-of-3 Escrow"), - "company" => ( - 3, - vec!["ceo", "cfo", "board1", "board2", "board3"], - "3-of-5 Company", - ), - "dao" => ( - 5, - vec![ - "member1", "member2", "member3", "member4", "member5", "member6", "member7", - "member8", "member9", - ], - "5-of-9 DAO Treasury", - ), - "vault" => (2, vec!["key1", "key2"], "2-of-2 Vault"), - "payment" => (1, vec!["approver1", "approver2"], "1-of-2 Payment"), - _ => anyhow::bail!("Unknown template: {}", template), - }; + let proposal = multisig::proposal_from_template(template)?; + let signers: Vec<&str> = proposal.signers.iter().map(String::as_str).collect(); - let proposal = multisig::Proposal::new( - threshold, - signers.iter().map(|s| s.to_string()).collect(), - "testnet".to_string(), - ); - - std::fs::write(output, serde_json::to_string_pretty(&proposal)?)?; + save_proposal(output, &proposal)?; println!(); - println!(" Template: {}", name); - println!(" Threshold: {}/{}", threshold, signers.len()); + println!( + " Template: {}", + proposal.metadata.title.as_deref().unwrap_or(template) + ); + println!(" Threshold: {}/{}", proposal.threshold, signers.len()); println!(" Signers: {}", signers.join(", ")); println!(); diff --git a/src/utils/multisig_builder.rs b/src/utils/multisig_builder.rs index 75493142..62ab8417 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 uuid::Uuid; @@ -31,6 +31,14 @@ pub struct ProposalMetadata { pub recipient: Option, } +#[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 { @@ -64,6 +72,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 { @@ -82,29 +93,152 @@ impl Proposal { pub fn signed_by(&self) -> Vec { self.signatures.iter().map(|s| s.signer.clone()).collect() } -} -pub fn generate_signature(wallet: &str) -> Result { - use hex; - use sha2::{Digest, Sha256}; + pub fn is_expired(&self) -> bool { + is_proposal_expired(self) + } +} - let mut hasher = Sha256::new(); - hasher.update(wallet.as_bytes()); - let result = hasher.finalize(); +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) +} - Ok(hex::encode(result)) +pub fn signing_message(proposal_id: &str, signer: &str) -> String { + format!("starforge-multisig:{proposal_id}:{signer}") } -pub fn verify_signature(signer: &str, signature: &str, message: &str) -> bool { +fn hash_message(message: &str) -> Result { use hex; use sha2::{Digest, Sha256}; let mut hasher = Sha256::new(); hasher.update(message.as_bytes()); - let result = hasher.finalize(); - let expected = hex::encode(result); + Ok(hex::encode(hasher.finalize())) +} + +pub fn generate_signature(proposal_id: &str, wallet: &str) -> Result { + hash_message(&signing_message(proposal_id, wallet)) +} + +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_signature_format(signature: &str) -> bool { + signature.len() == 64 && signature.chars().all(|c| c.is_ascii_hexdigit()) +} + +pub fn validate_for_signing(proposal: &Proposal, wallet: &str) -> Result<()> { + if proposal.is_expired() { + bail!("Proposal has expired"); + } + 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 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 + ); + } + + 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(()) +} + +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) +} - expected == signature +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", + }, + ] +} + +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 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)] @@ -136,30 +270,65 @@ 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)] mod tests { use super::*; @@ -206,4 +375,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 d68c1120..30df1322 100644 --- a/tests/cli_smoke.rs +++ b/tests/cli_smoke.rs @@ -466,3 +466,144 @@ fn config_help_lists_doctor_subcommand() { let stdout = String::from_utf8_lossy(&output.stdout); assert!(stdout.contains("doctor")); } + +#[test] +fn multisig_templates_lists_scenarios() { + let home = isolated_home(); + let output = starforge(home.path()) + .args(["multisig", "templates"]) + .output() + .expect("spawn multisig templates"); + assert_success(&output, "starforge multisig templates"); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("escrow")); + assert!(stdout.contains("dao")); +} + +#[test] +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([ + "multisig", + "from-template", + "escrow", + "--output", + output_path.to_str().unwrap(), + ]) + .output() + .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")); +}