diff --git a/docs/COMMAND_REFERENCE.md b/docs/COMMAND_REFERENCE.md index 09c46af1..6a87b1ed 100644 --- a/docs/COMMAND_REFERENCE.md +++ b/docs/COMMAND_REFERENCE.md @@ -52,6 +52,29 @@ starforge tutorial next --- +## `multisig` + +| Subcommand | Purpose | +|------------|---------| +| `wizard` | Interactive transaction proposal builder | +| `create` | Create a proposal with threshold, signers, metadata, and optional transaction XDR | +| `status ` | Show visual signature collection progress | +| `verify ` | Validate signatures, duplicates, pending signers, and threshold readiness | +| `notify ` | Queue signature request notifications for pending signers | +| `export ` / `import ` | Share proposal JSON between signers | +| `templates` / `from-template` | Use common scenarios like escrow, company treasury, DAO, vault, and payment | + +```bash +starforge multisig wizard +starforge multisig create --threshold 2 --signers alice,bob,carol \ + --title "Treasury payment" --transaction-xdr +starforge multisig status proposal.json +starforge multisig verify proposal.json +starforge multisig notify proposal.json --message "Please sign the treasury payment" +``` + +--- + ## `new` | Subcommand | Purpose | diff --git a/src/commands/multisig_builder.rs b/src/commands/multisig_builder.rs index 15b73971..3d381cbb 100644 --- a/src/commands/multisig_builder.rs +++ b/src/commands/multisig_builder.rs @@ -1,7 +1,10 @@ -use crate::utils::{multisig_builder as multisig, print as p}; +use crate::utils::{multisig_builder as multisig, notifications, print as p}; use anyhow::Result; use clap::Subcommand; -use std::path::PathBuf; +use colored::Colorize; +use dialoguer::{theme::ColorfulTheme, Confirm, Input, Select}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; #[derive(Subcommand)] pub enum MultisigCommands { @@ -10,25 +13,36 @@ pub enum MultisigCommands { /// Minimum signatures required #[arg(long)] threshold: u32, - /// Signers (comma-separated public keys) + /// Signers (comma-separated names or public keys) #[arg(long)] signers: String, /// Transaction network #[arg(long, default_value = "testnet")] network: String, + /// Human-readable proposal title + #[arg(long)] + title: Option, + /// Proposal description + #[arg(long)] + description: Option, + /// Transaction envelope/XDR to collect signatures for + #[arg(long)] + transaction_xdr: Option, }, + /// Interactive multi-sig transaction builder + Wizard, /// Add a signer to proposal AddSigner { /// Proposal file path proposal: PathBuf, - /// Signer public key + /// Signer name or public key signer: String, }, - /// Sign proposal with wallet + /// Sign proposal with wallet/signer name Sign { /// Proposal file path proposal: PathBuf, - /// Signer wallet name + /// Signer wallet/name wallet: String, }, /// View proposal details and signatures @@ -41,6 +55,19 @@ 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 { + /// Proposal file path + proposal: PathBuf, + /// Optional custom notification message + #[arg(long)] + message: Option, + }, /// Submit signed proposal to network Submit { /// Proposal file path @@ -71,6 +98,9 @@ pub enum MultisigCommands { template: String, /// Output file path output: PathBuf, + /// Transaction network + #[arg(long, default_value = "testnet")] + network: String, }, } @@ -80,203 +110,328 @@ pub async fn handle(cmd: MultisigCommands) -> Result<()> { threshold, signers, network, - } => create_proposal(threshold, &signers, &network), + title, + description, + transaction_xdr, + } => create_proposal( + threshold, + &signers, + &network, + title, + description, + transaction_xdr, + ), + MultisigCommands::Wizard => interactive_wizard(), MultisigCommands::AddSigner { proposal, signer } => add_signer(&proposal, &signer), 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::Submit { proposal, network } => submit_proposal(&proposal, &network), MultisigCommands::Export { proposal, output } => export_proposal(&proposal, output), MultisigCommands::Import { input, output } => import_proposal(&input, output), MultisigCommands::Templates => list_templates(), - MultisigCommands::FromTemplate { template, output } => from_template(&template, &output), + MultisigCommands::FromTemplate { + template, + output, + network, + } => from_template(&template, &output, &network), } } -fn create_proposal(threshold: u32, signers: &str, network: &str) -> Result<()> { - p::info(&format!( - "Creating {}-of-{} multi-sig proposal", - threshold, - signers.split(',').count() - )); +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())?; + + 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()); - let signer_list: Vec = signers.split(',').map(|s| s.trim().to_string()).collect(); + save_proposal(Path::new(&filename), &proposal)?; - if threshold as usize > signer_list.len() { - anyhow::bail!("Threshold cannot exceed number of signers"); + println!(); + p::success(&format!("Proposal created: {}", filename)); + p::kv( + "Threshold", + &format!("{}/{}", threshold, proposal.signers.len()), + ); + p::kv("Network", network); + print_progress(&proposal); + Ok(()) +} + +fn interactive_wizard() -> Result<()> { + let theme = ColorfulTheme::default(); + p::header("Interactive Multi-Sig Builder"); + + let templates = multisig::common_templates(); + let mut choices = vec!["blank custom proposal".to_string()]; + choices.extend( + templates + .iter() + .map(|template| format!("{} - {}", template.name, template.description)), + ); + + let selected = Select::with_theme(&theme) + .with_prompt("Choose a starting point") + .items(&choices) + .default(0) + .interact()?; + + let network: String = Input::with_theme(&theme) + .with_prompt("Network") + .default("testnet".to_string()) + .interact_text()?; + + let mut proposal = if selected == 0 { + let threshold: u32 = Input::with_theme(&theme) + .with_prompt("Required signatures") + .default(2) + .interact_text()?; + let signers: String = Input::with_theme(&theme) + .with_prompt("Signers (comma-separated names or public keys)") + .interact_text()?; + let signer_list = parse_signers(&signers); + validate_threshold(threshold, signer_list.len())?; + multisig::Proposal::new(threshold, signer_list, network) + } else { + multisig::proposal_from_template(templates[selected - 1].name, network)? + }; + + let title: String = Input::with_theme(&theme) + .with_prompt("Title") + .default( + proposal + .metadata + .title + .clone() + .unwrap_or_else(|| "Multi-sig transaction".to_string()), + ) + .interact_text()?; + proposal.metadata.title = Some(title); + + let description: String = Input::with_theme(&theme) + .with_prompt("Description") + .allow_empty(true) + .interact_text()?; + if !description.trim().is_empty() { + proposal.metadata.description = Some(description); } - let proposal = multisig::Proposal::new(threshold, signer_list, network.to_string()); - let filename = format!("proposal_{}.json", uuid::Uuid::new_v4()); + let transaction_xdr: String = Input::with_theme(&theme) + .with_prompt("Transaction XDR/envelope (optional)") + .allow_empty(true) + .interact_text()?; + if !transaction_xdr.trim().is_empty() { + proposal.transaction_xdr = Some(transaction_xdr); + } - std::fs::write(&filename, serde_json::to_string_pretty(&proposal)?)?; + 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)?; println!(); - println!(" Proposal: {}", colored::Colorize::cyan(filename.as_str())); - println!(" Threshold: {}/{}", threshold, signers.split(',').count()); - println!(" Network: {}", network); - println!(); - - p::success(&format!("Proposal created: {}", filename)); + p::success(&format!("Proposal created: {}", output)); + print_progress(&proposal); + + if Confirm::with_theme(&theme) + .with_prompt("Queue signature request notifications now?") + .default(false) + .interact()? + { + notify_for_proposal(&proposal, None)?; + } Ok(()) } -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)?; +fn add_signer(proposal_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"); } 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)); - + print_progress(&proposal); Ok(()) } -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)?; +fn sign_proposal(proposal_path: &Path, wallet: &str) -> Result<()> { + let mut proposal = load_proposal(proposal_path)?; - p::info(&format!("Signing proposal with wallet '{}'", wallet)); + p::info(&format!("Signing proposal with '{}'", wallet)); - let signature = multisig::generate_signature(wallet)?; - proposal.add_signature(wallet.to_string(), signature); + let signature = multisig::generate_proposal_signature(wallet, &proposal)?; + proposal.add_signature_checked(wallet.to_string(), signature)?; + save_proposal(proposal_path, &proposal)?; - std::fs::write(proposal_path, serde_json::to_string_pretty(&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: &std::path::Path) -> Result<()> { - let contents = std::fs::read_to_string(proposal_path)?; - let proposal: multisig::Proposal = serde_json::from_str(&contents)?; +fn view_proposal(proposal_path: &Path) -> Result<()> { + let proposal = load_proposal(proposal_path)?; println!(); - println!("{}", colored::Colorize::cyan("═══ PROPOSAL ═══")); - println!("ID: {}", proposal.id); - println!("Network: {}", proposal.network); - println!( - "Threshold: {}/{}", - proposal.threshold, - proposal.signers.len() + p::header("Multi-Sig Proposal"); + p::kv_accent("ID", &proposal.id); + p::kv("Network", &proposal.network); + p::kv( + "Threshold", + &format!("{}/{}", proposal.threshold, proposal.signers.len()), ); - println!("Status: {}", proposal.get_status()); - println!("Created: {}", proposal.created_at); - println!(); + p::kv("Status", &proposal.get_status()); + p::kv("Created", &proposal.created_at); + if let Some(title) = &proposal.metadata.title { + p::kv("Title", title); + } + if let Some(tx_type) = &proposal.metadata.transaction_type { + p::kv("Type", tx_type); + } + if let Some(xdr) = &proposal.transaction_xdr { + p::kv("Transaction", &preview(xdr, 40)); + } + print_progress(&proposal); - println!("{}", colored::Colorize::cyan("═══ SIGNERS ═══")); + println!(); + p::info("Signers"); for (idx, signer) in proposal.signers.iter().enumerate() { - let signed = proposal.signatures.iter().any(|s| s.signer == *signer); + let signed = proposal.signatures.iter().any(|sig| sig.signer == *signer); let marker = if signed { - colored::Colorize::green("✓") + "signed".green() } else { - colored::Colorize::red("✗") + "pending".yellow() }; - println!(" {} {}. {}", marker, idx + 1, signer); + println!(" {:>2}. {:<8} {}", idx + 1, marker, signer); } println!(); - println!("{}", colored::Colorize::cyan("═══ SIGNATURES ═══")); - for sig in &proposal.signatures { - println!(" ✓ {}: {}", sig.signer, &sig.signature[..16]); + p::info("Signatures"); + if proposal.signatures.is_empty() { + println!(" none"); + } else { + for sig in &proposal.signatures { + println!(" {}: {}", sig.signer, preview(&sig.signature, 16)); + } } println!(); - Ok(()) } -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 total = proposal.signers.len(); - let signed = proposal.signatures.len(); - let remaining = proposal.threshold as usize - signed; +fn check_status(proposal_path: &Path) -> Result<()> { + let proposal = load_proposal(proposal_path)?; + let validation = multisig::validate_signatures(&proposal); println!(); - println!("{}", colored::Colorize::cyan("═══ SIGNATURE STATUS ═══")); - println!("Progress: {}/{}", signed, proposal.threshold); + p::header("Signature Status"); + print_progress(&proposal); - let percent = (signed as f32 / proposal.threshold as f32 * 100.0) as i32; - let filled = (percent / 10) as usize; - let empty = 10 - filled; + 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(()) +} - print!(" ["); - for _ in 0..filled { - print!("{}", colored::Colorize::green("█")); +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(", ") + )); } - for _ in 0..empty { - print!("{}", colored::Colorize::red("░")); + if !validation.duplicate_signers.is_empty() { + p::warn(&format!( + "Duplicate signatures from: {}", + validation.duplicate_signers.join(", ") + )); } - println!("] {}%", percent); - - println!(); - if remaining > 0 { - println!(" {} signatures remaining", remaining); - println!(); - for signer in &proposal.signers { - if !proposal.signatures.iter().any(|s| s.signer == *signer) { - println!(" ⏳ Waiting for: {}", signer); - } - } - } else { - println!( - " {} All signatures collected!", - colored::Colorize::green("✓") - ); + if !validation.missing_signers.is_empty() { + p::info(&format!( + "Pending signers: {}", + validation.missing_signers.join(", ") + )); } - println!(); + if !validation.invalid_signers.is_empty() || !validation.duplicate_signers.is_empty() { + anyhow::bail!("Proposal contains invalid signature data"); + } 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)?; +fn notify_signers(proposal_path: &Path, message: Option) -> Result<()> { + let proposal = load_proposal(proposal_path)?; + notify_for_proposal(&proposal, message) +} - if proposal.signatures.len() < proposal.threshold as usize { +fn submit_proposal(proposal_path: &Path, network: &str) -> Result<()> { + let proposal = load_proposal(proposal_path)?; + let validation = multisig::validate_signatures(&proposal); + + if !validation.ready { anyhow::bail!( - "Not enough signatures: {}/{}", - proposal.signatures.len(), + "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`." + ); + } p::info(&format!("Submitting proposal to {}", network)); - println!( - " Signatures: {}/{}", - proposal.signatures.len(), - proposal.threshold + p::kv( + "Signatures", + &format!("{}/{}", validation.valid_signatures, proposal.threshold), ); - println!(); + if let Some(xdr) = &proposal.transaction_xdr { + p::kv("Transaction", &preview(xdr, 40)); + } p::success("Proposal submitted successfully"); println!(" Hash: abc123def456..."); println!(); - Ok(()) } -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)?; - +fn export_proposal(proposal_path: &Path, output: Option) -> Result<()> { + let proposal = load_proposal(proposal_path)?; let output_file = output.unwrap_or_else(|| { PathBuf::from(format!( "proposal_export_{}.json", @@ -284,89 +439,134 @@ 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())); - Ok(()) } -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)?; - +fn import_proposal(input_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()))); - std::fs::write(&output_file, serde_json::to_string_pretty(&proposal)?)?; - + save_proposal(&output_file, &proposal)?; p::success(&format!("Proposal imported: {}", output_file.display())); - + print_progress(&proposal); Ok(()) } fn list_templates() -> Result<()> { println!(); - println!("{}", colored::Colorize::cyan("═══ MULTI-SIG TEMPLATES ═══")); + p::header("Multi-Sig Templates"); + for template in multisig::common_templates() { + println!( + " {} - {} [{}/{}]", + template.name.yellow(), + template.description, + template.threshold, + template.signers.len() + ); + } println!(); + println!("Usage: starforge multisig from-template