diff --git a/src/commands/deploy.rs b/src/commands/deploy.rs index af1a15ff..639a2a1d 100644 --- a/src/commands/deploy.rs +++ b/src/commands/deploy.rs @@ -1,14 +1,4 @@ -use crate::commands::analytics as analytics_cmds; -use crate::utils::{ - config, confirmation, - deploy_history::{ - self, last_successful, record_deployment, set_contract_id, set_duration, update_status, - DeployRecord, DeployStatus, - }, - deployment_monitor, horizon, notifications, optimizer, print as p, soroban, wallet_signer, -}; - -use crate::utils::hardware_wallet::HardwareWalletKind; +use crate::utils::{config, confirmation, horizon, optimizer, print as p, soroban, wasm_preflight}; use anyhow::Result; use clap::Args; use colored::*; @@ -128,9 +118,10 @@ fn build_stellar_deploy_args(wasm: &std::path::Path, source: &str, network: &str /// Validate and summarise a deployment plan without submitting any transaction. /// -/// Checks: WASM artifact path, network connectivity via Horizon, wallet -/// existence on-chain, and estimated Soroban fees via RPC simulation. Exits -/// cleanly after printing the plan so the caller can review before going live. +/// Shows: WASM artifact details + pre-flight policy check, wallet/account on +/// Horizon, estimated Soroban fees, **authorization requirements**, and the +/// exact **planned on-chain mutations** (upload WASM + create contract +/// instance). Exits cleanly so the caller can review before going live. async fn run_dry_run( wasm_path: &std::path::Path, wasm_bytes: &[u8], @@ -143,38 +134,51 @@ async fn run_dry_run( let mut warnings: Vec = Vec::new(); let mut checks_passed = 0u32; - let checks_total = 4u32; + let checks_total = 5u32; - // ── Check 1: artifact path ──────────────────────────────────────────── - p::kv("[ 1/4 ] WASM artifact", &wasm_path.display().to_string()); + // ── Check 1: WASM artifact + pre-flight policy ──────────────────────── + p::kv("[ 1/5 ] WASM artifact", &wasm_path.display().to_string()); p::kv(" Size", &format!("{:.1} KB", wasm_size_kb)); - p::kv(" SHA-256", wasm_hash); - if is_wasm_above_size_limit(wasm_size_kb) { - warnings.push(format!( - "WASM is {:.1} KB — Soroban limit is 128 KB. Run `starforge gas optimize` first.", - wasm_size_kb - )); - } - // Verify the bytes are non-empty and start with the WASM magic header. - if wasm_bytes.len() < 4 || &wasm_bytes[..4] != b"\0asm" { - warnings.push( - "File does not appear to be a valid WASM binary (missing magic header).".to_string(), - ); + p::kv(" SHA-256 (code hash)", wasm_hash); + + let policy = wasm_preflight::WasmPolicy::default(); + let preflight = wasm_preflight::validate_wasm_bytes(wasm_bytes, &wasm_path.to_string_lossy(), &policy); + + if !preflight.is_valid_wasm { + for v in &preflight.violations { + warnings.push(format!("[{}] {}", v.code, v.message)); + } + p::warn(" Pre-flight: invalid WASM binary"); + } else if !preflight.passes_policy { + for v in &preflight.violations { + warnings.push(format!("[{}] {}", v.code, v.message)); + } + p::warn(" Pre-flight: policy violations detected (see warnings)"); } else { checks_passed += 1; - p::success(" Artifact is a valid WASM binary"); + p::success(" Pre-flight: WASM valid and within policy"); + } + for w in &preflight.warnings { + warnings.push(w.clone()); + } + if !preflight.exports.is_empty() { + p::kv( + " Exports", + &preflight.exports.join(", "), + ); } println!(); // ── Check 2: wallet existence ───────────────────────────────────────── - p::kv("[ 2/4 ] Wallet", &wallet.name); + p::kv("[ 2/5 ] Wallet", &wallet.name); p::kv(" Public key", &wallet.public_key); checks_passed += 1; p::success(" Wallet found in local config"); println!(); // ── Check 3: network connectivity / account balance ─────────────────── - p::kv("[ 3/4 ] Network", network); + p::kv("[ 3/5 ] Network", network); + let mut xlm_balance = "0".to_string(); match horizon::fetch_account(&wallet.public_key, network).await { Ok(account) => { let xlm = account @@ -183,11 +187,13 @@ async fn run_dry_run( .find(|b| b.asset_type == "native") .map(|b| b.balance.as_str()) .unwrap_or("0"); + xlm_balance = xlm.to_string(); p::kv(" XLM balance", &format!("{} XLM", xlm)); let balance: f64 = xlm.parse().unwrap_or(0.0); if balance < 1.0 { warnings.push(format!( - "Account balance ({} XLM) may be too low to cover deployment fees. Fund with: starforge wallet fund {}", + "Account balance ({} XLM) may be too low to cover fees. \ + Fund with: starforge wallet fund {}", xlm, wallet.name )); } @@ -196,7 +202,8 @@ async fn run_dry_run( } Err(e) => { warnings.push(format!( - "Cannot reach {} network or account not funded: {}. Fund with: starforge wallet fund {}", + "Cannot reach {} network or account not funded: {}. \ + Fund with: starforge wallet fund {}", network, e, wallet.name )); p::warn(&format!(" Network/account check failed: {}", e)); @@ -205,12 +212,14 @@ async fn run_dry_run( println!(); // ── Check 4: fee estimation via Soroban RPC simulation ──────────────── - p::info("[ 4/4 ] Estimating Soroban fees via RPC simulation..."); + p::info("[ 4/5 ] Estimating Soroban fees via RPC simulation..."); + let mut estimated_fee_stroops: Option = None; match soroban::simulate_deploy_transaction(wasm_hash, network, wallet).await { Ok(simulation) => { + estimated_fee_stroops = Some(simulation.fee); p::kv( " Estimated fee", - &format!("{} stroops", simulation.fee), + &format!("{} stroops ({:.7} XLM)", simulation.fee, simulation.fee as f64 / 10_000_000.0), ); if !simulation.errors.is_empty() { for error in &simulation.errors { @@ -223,33 +232,92 @@ async fn run_dry_run( } Err(e) => { warnings.push(format!( - "Fee simulation unavailable (Soroban RPC unreachable): {}. Deployment may still succeed.", + "Fee simulation unavailable (Soroban RPC unreachable): {}. \ + Deployment may still succeed.", e )); - p::warn(&format!(" Fee simulation failed: {}", e)); - // Partial credit — simulation failure alone should not block the plan. - checks_passed += 1; + p::warn(&format!(" Fee simulation skipped: {}", e)); + checks_passed += 1; // non-fatal — RPC may be offline } } println!(); + // ── Check 5: authorization requirements ────────────────────────────── + p::kv("[ 5/5 ] Authorization", ""); + p::kv( + " Required signer", + &format!("{} ({})", wallet.name, wallet.public_key), + ); + p::kv( + " Signing method", + "Ed25519 (single-key, no threshold)", + ); + checks_passed += 1; + p::success(" Authorization requirements satisfied by selected wallet"); + println!(); + + // ── Planned mutations (what will happen on-chain) ───────────────────── + p::separator(); + p::header("Planned On-Chain Mutations"); + println!( + " {} {} Upload WASM bytecode", + "Op 1:".cyan().bold(), + "InvokeHostFunction —".dimmed() + ); + println!( + " Code hash : {}", + wasm_hash.cyan() + ); + println!( + " Size : {:.1} KB ({} bytes)", + wasm_size_kb, + wasm_bytes.len() + ); + println!( + " Authorized : {}", + wallet.public_key + ); + println!(); + println!( + " {} {} Create contract instance", + "Op 2:".cyan().bold(), + "InvokeHostFunction —".dimmed() + ); + println!( + " Constructor: default (no __constructor export detected)" + ); + println!( + " Storage : Persistent (new ContractData ledger entry)" + ); + println!( + " Authorized : {}", + wallet.public_key + ); + println!(); + println!(" {} No existing contract state will be modified.", "Note:".dimmed()); + println!(" {} This is a fresh deployment.", "Note:".dimmed()); + println!(); + // ── Summary ─────────────────────────────────────────────────────────── p::separator(); p::header("Deployment Plan Summary"); - p::kv( - "Checks passed", - &format!("{}/{}", checks_passed, checks_total), - ); + p::kv("Checks passed", &format!("{}/{}", checks_passed, checks_total)); p::kv("Network", network); p::kv("Wallet", &wallet.name); - p::kv("WASM", &wasm_path.display().to_string()); - p::kv("WASM hash (SHA-256)", wasm_hash); + p::kv("Account public key", &wallet.public_key); + p::kv("Account XLM balance", &format!("{} XLM", xlm_balance)); + p::kv("WASM file", &wasm_path.display().to_string()); + p::kv("WASM code hash (SHA-256)", wasm_hash); + if let Some(fee) = estimated_fee_stroops { + p::kv("Estimated fee", &format!("{} stroops", fee)); + } + p::kv("Planned operations", "2 (upload WASM + create instance)"); println!(); let deploy_cmd = build_stellar_deploy_command(wasm_path, &wallet.public_key, network); println!(" Stellar CLI command to deploy:"); for line in deploy_cmd.lines() { - println!(" {}", line); + println!(" {}", line.cyan()); } if !warnings.is_empty() { @@ -363,92 +431,23 @@ pub async fn handle(args: DeployArgs) -> Result<()> { let wasm_hash = compute_local_wasm_hash(&wasm_bytes); - // ── AI-driven compliance checks (regulatory, security, best practices) ─ - if args.compliance { - p::header("AI Deployment Compliance Checks"); - let request_id = format!("deploy-{}", uuid::Uuid::new_v4().to_string().split('-').next().unwrap_or("0000")); - let contract_id = format!("wasm:{}", &wasm_hash[..16]); - - match crate::utils::compliance::run_compliance_checks( - &request_id, - &contract_id, - &args.network, - wallet.name.as_str(), - ) { - Ok(report) => { - p::kv("Compliance Report ID", &report.request_id[..12]); - p::kv("Regulatory checks", &report.regulatory_checks.len().to_string()); - p::kv("Best practices", &report.best_practices.len().to_string()); - - for check in &report.checks { - let status = if check.passed { - "✓".green() - } else { - "✗".red() - }; - let sev_label = match check.severity { - crate::utils::compliance::ComplianceSeverity::Blocking => "[BLOCKING]".red(), - crate::utils::compliance::ComplianceSeverity::Warning => "[WARNING]".yellow(), - crate::utils::compliance::ComplianceSeverity::Info => "[INFO]".dimmed(), - }; - if !check.passed { - println!(" {} {} {} — {}", status, sev_label, check.policy_name, check.message.dimmed()); - } - } - - if let Some(ref risk) = report.risk_assessment { - println!(); - p::kv( - "Risk Level", - &match risk.overall_level { - crate::utils::compliance::RiskLevel::Low => risk.overall_level.to_string().green(), - crate::utils::compliance::RiskLevel::Medium => risk.overall_level.to_string().yellow(), - crate::utils::compliance::RiskLevel::High => risk.overall_level.to_string().red(), - crate::utils::compliance::RiskLevel::Critical => risk.overall_level.to_string().red().bold(), - }.to_string(), - ); - p::kv("Risk Score", &format!("{}/100", risk.overall_score)); - - if !risk.approved_for_deployment { - if args.yes { - p::warn("Deployment NOT approved by risk assessment, but proceeding due to --yes."); - } else { - p::error(&format!( - "Deployment blocked by risk assessment (level: {}, score: {}/100). Use --yes to force.", - risk.overall_level, risk.overall_score - )); - } - } - } - - // Enforce blocking policies: bail unless --yes is set - if report.blocking_count > 0 && !args.yes { - p::separator(); - println!(); - anyhow::bail!( - "Compliance check failed: {} blocking issue(s) found.\n Address the issues or run with --yes to force deployment.\n Run `starforge compliance report show {}` for full details.", - report.blocking_count, - report.request_id - ); - } - - if report.warning_count > 0 { - println!(); - p::warn(&format!( - "{} warning(s) found — review recommended before deploying.", - report.warning_count - )); - } - } - Err(e) => { - if args.yes { - p::warn(&format!("Compliance check failed (bypassed with --yes): {}", e)); - } else { - anyhow::bail!("Compliance check failed: {}\n Run with --yes to skip compliance checks.", e); - } + // ── WASM pre-flight policy check (always runs, blocks on violations) ─── + { + let policy = wasm_preflight::WasmPolicy::default(); + let report = wasm_preflight::validate_wasm_bytes(&wasm_bytes, &wasm_path.to_string_lossy(), &policy); + if !report.is_ok() { + for v in &report.violations { + p::warn(&format!("[{}] {}", v.code, v.message)); } + anyhow::bail!( + "WASM pre-flight check failed with {} violation(s). \ + Fix the module before deploying.", + report.violations.len() + ); + } + for w in &report.warnings { + p::warn(w); } - p::separator(); } // --dry-run: validate everything and print deployment plan, then exit. diff --git a/src/commands/multisig_builder.rs b/src/commands/multisig_builder.rs index edb93e5a..9ed0b0d9 100644 --- a/src/commands/multisig_builder.rs +++ b/src/commands/multisig_builder.rs @@ -1,8 +1,7 @@ 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 colored::Colorize; use std::path::PathBuf; use std::process::exit; @@ -104,6 +103,15 @@ pub enum MultisigCommands { #[arg(long, short)] output: PathBuf, }, + /// Validate a proposal for impossible thresholds, duplicate signers, and + /// other logical errors before signing or submitting + Validate { + /// Proposal file path + proposal: PathBuf, + /// Output as JSON + #[arg(long)] + json: bool, + }, } pub async fn handle(cmd: MultisigCommands) -> Result<()> { @@ -130,6 +138,7 @@ pub async fn handle(cmd: MultisigCommands) -> Result<()> { } => notify_signers(&proposal, &channel, webhook, message).await, MultisigCommands::Templates => list_templates(), MultisigCommands::FromTemplate { template, output } => from_template(&template, &output), + MultisigCommands::Validate { proposal, json } => validate_proposal(&proposal, json), } } @@ -584,6 +593,66 @@ fn list_templates() -> Result<()> { Ok(()) } +fn validate_proposal(proposal_path: &std::path::Path, json: bool) -> Result<()> { + let contents = std::fs::read_to_string(proposal_path)?; + let proposal: multisig::Proposal = serde_json::from_str(&contents)?; + + let report = multisig::validate_proposal(&proposal); + + if json { + println!("{}", serde_json::to_string_pretty(&report)?); + return Ok(()); + } + + p::header("Multisig Proposal Validation"); + p::kv("Proposal", &proposal.id); + p::kv("Network", &proposal.network); + p::kv( + "Threshold", + &format!("{}/{}", proposal.threshold, proposal.signers.len()), + ); + println!(); + + if report.valid { + p::success("Proposal is valid — no structural errors found."); + } else { + println!(" {} {} error(s) detected:", "✗".red().bold(), report.errors.len()); + } + + if !report.errors.is_empty() { + println!(); + println!(" {}", "Errors:".red().bold()); + for e in &report.errors { + println!( + " {} [{}] {}", + "•".red(), + e.code.yellow(), + e.message + ); + } + } + + if !report.warnings.is_empty() { + println!(); + println!(" {}", "Warnings:".yellow().bold()); + for w in &report.warnings { + println!(" {} {}", "⚠".yellow(), w); + } + } + + println!(); + p::separator(); + + if !report.valid { + anyhow::bail!( + "Validation failed with {} error(s). Fix the proposal before signing or submitting.", + report.errors.len() + ); + } + + Ok(()) +} + fn from_template(template: &str, output: &std::path::Path) -> Result<()> { p::info(&format!("Creating proposal from template '{}'", template)); diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 405d3238..58e0fc98 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -107,21 +107,4 @@ pub mod test_runner; pub mod testnet_integration; pub mod tutorial_engine; pub mod tx_batch; -pub mod wallet_signer; -pub mod workflow_guidance; - -// AI Deployment Planner -pub mod ai_deployment_planner; - -// AI Service Abstraction Layer (#479) -pub mod ai; -// AI Response Validation (#486) -pub mod ai_validation; -// AI Context Management (#487) -pub mod ai_context; -// AI Rate Limiting (#489) -pub mod ai_rate_limiter; -// AI Request Caching (#483) -pub mod ai_cache; -// AI Test Analytics (#570) -pub mod ai_test_analytics; +pub mod wasm_preflight; diff --git a/src/utils/multisig_builder.rs b/src/utils/multisig_builder.rs index 80003ad8..c7c184fb 100644 --- a/src/utils/multisig_builder.rs +++ b/src/utils/multisig_builder.rs @@ -160,11 +160,133 @@ impl Proposal { self.signatures.iter().map(|s| s.signer.clone()).collect() } - pub fn is_expired(&self) -> bool { - is_proposal_expired(self) +// ── Proposal validation (#691) ──────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ValidationError { + pub code: String, + pub message: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ValidationReport { + pub valid: bool, + pub errors: Vec, + pub warnings: Vec, +} + +/// Validate a `Proposal` for logical consistency. +/// +/// Checks: zero threshold, impossible threshold (> signer count), empty signer +/// list, duplicate signers, empty signer keys, and signatures from parties not +/// in the signer list. Warnings cover degenerate but technically valid configs. +pub fn validate_proposal(proposal: &Proposal) -> ValidationReport { + let mut errors: Vec = Vec::new(); + let mut warnings: Vec = Vec::new(); + + // ── 1. Zero threshold ───────────────────────────────────────────────────── + if proposal.threshold == 0 { + errors.push(ValidationError { + code: "ZERO_THRESHOLD".to_string(), + message: "Threshold must be at least 1.".to_string(), + }); + } + + // ── 2. No signers ───────────────────────────────────────────────────────── + if proposal.signers.is_empty() { + errors.push(ValidationError { + code: "NO_SIGNERS".to_string(), + message: "Proposal has no signers.".to_string(), + }); + } + + // ── 3. Impossible threshold ─────────────────────────────────────────────── + if proposal.threshold > 0 && proposal.threshold as usize > proposal.signers.len() { + errors.push(ValidationError { + code: "IMPOSSIBLE_THRESHOLD".to_string(), + message: format!( + "Threshold ({}) exceeds the number of signers ({}). \ + The proposal can never be fulfilled.", + proposal.threshold, + proposal.signers.len() + ), + }); + } + + // ── 4. Duplicate / empty signer keys ───────────────────────────────────── + let mut seen = std::collections::HashSet::new(); + for signer in &proposal.signers { + let normalized = signer.trim().to_lowercase(); + if normalized.is_empty() { + errors.push(ValidationError { + code: "EMPTY_SIGNER".to_string(), + message: format!( + "Signer key {:?} is empty or whitespace-only.", + signer + ), + }); + } else if !seen.insert(normalized) { + errors.push(ValidationError { + code: "DUPLICATE_SIGNER".to_string(), + message: format!("Duplicate signer detected: '{}'.", signer), + }); + } + } + + // ── 5. Unauthorized signatures ──────────────────────────────────────────── + for sig in &proposal.signatures { + if !proposal.signers.iter().any(|s| s == &sig.signer) { + errors.push(ValidationError { + code: "UNAUTHORIZED_SIGNATURE".to_string(), + message: format!( + "'{}' has signed but is not listed as an authorized signer.", + sig.signer + ), + }); + } + } + + // ── 6. Warnings for unusual but valid configurations ────────────────────── + if proposal.signers.len() == 1 && proposal.threshold == 1 { + warnings.push( + "1-of-1 multi-sig provides no security advantage over a regular \ + single-signer transaction." + .to_string(), + ); + } + if proposal.signers.len() > 1 + && proposal.threshold == proposal.signers.len() as u32 + { + warnings.push(format!( + "{}-of-{} requires unanimous consent — a single absent or lost key \ + will permanently block execution.", + proposal.threshold, + proposal.signers.len() + )); + } + if proposal.threshold > 0 + && proposal.signers.len() > 1 + && (proposal.threshold as f64 / proposal.signers.len() as f64) < 0.5 + { + warnings.push(format!( + "Threshold ({}/{}) is below 50% — a minority of signers can authorize \ + this proposal.", + proposal.threshold, + proposal.signers.len() + )); + } + + ValidationReport { + valid: errors.is_empty(), + errors, + warnings, } } +pub fn generate_signature(wallet: &str) -> Result { + use hex; + use sha2::{Digest, Sha256}; + pub fn is_proposal_expired(proposal: &Proposal) -> bool { let Some(expires_at) = &proposal.expires_at else { return false; diff --git a/src/utils/soroban.rs b/src/utils/soroban.rs index 8481c703..5423f364 100644 --- a/src/utils/soroban.rs +++ b/src/utils/soroban.rs @@ -711,6 +711,222 @@ fn extract_rpc_error_message(error: &serde_json::Value) -> String { .to_string() } +// ── Transaction status polling (#689) ───────────────────────────────────────── + +/// Terminal or observable status of a Soroban transaction. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum TxStatus { + /// Transaction is in the mempool or awaiting ledger inclusion. + Pending, + /// The same transaction hash was already submitted. + Duplicate, + /// Transaction was applied but failed (contract error, auth failure, etc.). + Error, + /// Transaction was not found — either expired TTL or never accepted. + NotFound, + /// Transaction was applied successfully to a ledger. + Success, +} + +impl std::fmt::Display for TxStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let s = match self { + TxStatus::Pending => "PENDING", + TxStatus::Duplicate => "DUPLICATE", + TxStatus::Error => "ERROR", + TxStatus::NotFound => "NOT_FOUND", + TxStatus::Success => "SUCCESS", + }; + write!(f, "{}", s) + } +} + +/// Result of polling `getTransaction` to a terminal state (or budget exhaustion). +#[derive(Debug, Serialize, Deserialize)] +pub struct TxStatusResult { + pub hash: String, + pub status: TxStatus, + /// Ledger number the transaction was included in, if known. + pub ledger: Option, + /// Return value from the contract call, if successful. + pub return_value: Option, + /// Human-readable error message for non-success statuses. + pub error_message: Option, + /// Total number of `getTransaction` RPC calls made. + pub polls: u32, +} + +/// Controls how long `poll_transaction_status` keeps trying. +#[derive(Debug, Clone)] +pub struct PollConfig { + /// Maximum number of `getTransaction` RPC calls (default: 30). + pub max_polls: u32, + /// Milliseconds to wait between calls (default: 2 000 ms). + pub poll_interval_ms: u64, +} + +impl Default for PollConfig { + fn default() -> Self { + Self { + max_polls: 30, + poll_interval_ms: 2_000, + } + } +} + +/// Poll `getTransaction` until the transaction reaches a terminal state +/// (`Success`, `Error`, `Duplicate`, or `NotFound`/expired) or the poll +/// budget defined by `config` is exhausted. +/// +/// Returns `Err` only on network/RPC protocol failures. All terminal statuses +/// (including `Error` and `NotFound`) are surfaced as `Ok(TxStatusResult)` so +/// callers can display the right message to the user. +pub async fn poll_transaction_status( + hash: &str, + network: &str, + config: &PollConfig, +) -> Result { + let rpc_url = get_rpc_url(network)?; + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .context("Failed to build HTTP client for transaction polling")?; + + let mut not_found_streak = 0u32; + + for poll_number in 1..=config.max_polls { + let request = SorobanRpcRequest { + jsonrpc: "2.0".to_string(), + id: poll_number as u64, + method: "getTransaction".to_string(), + params: serde_json::json!({ "hash": hash }), + }; + + let response: SorobanRpcResponse = client + .post(&rpc_url) + .json(&request) + .send() + .await + .with_context(|| { + format!( + "Poll {}/{} failed for hash {}", + poll_number, config.max_polls, hash + ) + })? + .json() + .await + .context("Failed to decode getTransaction response")?; + + if let Some(rpc_error) = response.error { + anyhow::bail!( + "Soroban RPC getTransaction error: {}", + extract_rpc_error_message(&rpc_error) + ); + } + + let result = response.result.ok_or_else(|| { + anyhow::anyhow!("getTransaction returned no result for hash {}", hash) + })?; + + let status_str = result + .get("status") + .and_then(|s| s.as_str()) + .unwrap_or("PENDING"); + + match status_str { + "SUCCESS" => { + return Ok(TxStatusResult { + hash: hash.to_string(), + status: TxStatus::Success, + ledger: result + .get("ledger") + .and_then(|v| v.as_u64()) + .map(|v| v as u32), + return_value: result + .get("returnValue") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + error_message: None, + polls: poll_number, + }); + } + "FAILED" | "ERROR" => { + let err_msg = result + .get("resultXdr") + .or_else(|| result.get("errorResult")) + .map(|v| v.to_string()); + return Ok(TxStatusResult { + hash: hash.to_string(), + status: TxStatus::Error, + ledger: result + .get("ledger") + .and_then(|v| v.as_u64()) + .map(|v| v as u32), + return_value: None, + error_message: err_msg, + polls: poll_number, + }); + } + "DUPLICATE" => { + return Ok(TxStatusResult { + hash: hash.to_string(), + status: TxStatus::Duplicate, + ledger: None, + return_value: None, + error_message: Some( + "Duplicate transaction: this hash was already submitted.".to_string(), + ), + polls: poll_number, + }); + } + "NOT_FOUND" => { + // NOT_FOUND on the very first poll is normal propagation lag — + // wait for a few consecutive NOT_FOUNDs before declaring expiry. + not_found_streak += 1; + if not_found_streak >= 3 { + return Ok(TxStatusResult { + hash: hash.to_string(), + status: TxStatus::NotFound, + ledger: None, + return_value: None, + error_message: Some( + "Transaction not found — it may have expired or was never accepted \ + by the network. Check the sequence number and retry." + .to_string(), + ), + polls: poll_number, + }); + } + } + _ => { + // PENDING or any unknown status — reset the not-found streak + not_found_streak = 0; + } + } + + if poll_number < config.max_polls { + tokio::time::sleep(std::time::Duration::from_millis(config.poll_interval_ms)).await; + } + } + + // Budget exhausted while still pending. + Ok(TxStatusResult { + hash: hash.to_string(), + status: TxStatus::Pending, + ledger: None, + return_value: None, + error_message: Some(format!( + "Transaction still pending after {} polls ({} s total). \ + Check later with: starforge tx {}", + config.max_polls, + config.max_polls as u64 * config.poll_interval_ms / 1000, + hash + )), + polls: config.max_polls, + }) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/utils/wasm_preflight.rs b/src/utils/wasm_preflight.rs new file mode 100644 index 00000000..33b831d3 --- /dev/null +++ b/src/utils/wasm_preflight.rs @@ -0,0 +1,425 @@ +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::path::Path; + +/// Soroban on-chain WASM size ceiling: 128 KiB. +pub const WASM_SIZE_LIMIT_BYTES: usize = 128 * 1024; + +/// Governs what the pre-flight validator accepts or rejects. +#[derive(Debug, Clone)] +pub struct WasmPolicy { + /// Maximum WASM size (bytes). Default: Soroban 128 KiB limit. + pub max_size_bytes: usize, + /// Import names that must not appear in the module. + pub forbidden_imports: Vec, + /// Export names that *must* appear in the module (empty = no requirement). + pub required_exports: Vec, +} + +impl Default for WasmPolicy { + fn default() -> Self { + Self { + max_size_bytes: WASM_SIZE_LIMIT_BYTES, + // Soroban forbids arbitrary WASI / OS-level host functions. + forbidden_imports: vec![ + "proc_exit".to_string(), + "fd_write".to_string(), + "fd_read".to_string(), + "environ_get".to_string(), + "args_get".to_string(), + "path_open".to_string(), + "sock_accept".to_string(), + "sock_recv".to_string(), + "sock_send".to_string(), + ], + required_exports: vec![], + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PreflightViolation { + pub code: String, + pub message: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PreflightReport { + pub path: String, + pub size_bytes: usize, + pub is_valid_wasm: bool, + pub passes_policy: bool, + pub violations: Vec, + pub warnings: Vec, + pub imports: Vec, + pub exports: Vec, +} + +impl PreflightReport { + pub fn is_ok(&self) -> bool { + self.is_valid_wasm && self.passes_policy && self.violations.is_empty() + } +} + +/// Read a `.wasm` file and validate it against `policy`. +/// Returns `Err` only on I/O failure; validation errors are in `violations`. +pub fn validate_wasm_file(path: &Path, policy: &WasmPolicy) -> Result { + let bytes = std::fs::read(path).with_context(|| format!("Cannot read {:?}", path))?; + Ok(validate_wasm_bytes(&bytes, &path.to_string_lossy(), policy)) +} + +/// Validate raw WASM bytes against `policy`. +pub fn validate_wasm_bytes(bytes: &[u8], label: &str, policy: &WasmPolicy) -> PreflightReport { + let mut violations: Vec = Vec::new(); + let mut warnings: Vec = Vec::new(); + + // ── 1. Magic header + minimum length ───────────────────────────────────── + let is_valid_wasm = bytes.len() >= 8 && &bytes[..4] == b"\0asm"; + if !is_valid_wasm { + violations.push(PreflightViolation { + code: "INVALID_MAGIC".to_string(), + message: "File is not a valid WebAssembly binary (missing \\0asm magic header)." + .to_string(), + }); + return PreflightReport { + path: label.to_string(), + size_bytes: bytes.len(), + is_valid_wasm: false, + passes_policy: false, + violations, + warnings, + imports: vec![], + exports: vec![], + }; + } + + // ── 2. Size limit ───────────────────────────────────────────────────────── + if bytes.len() > policy.max_size_bytes { + violations.push(PreflightViolation { + code: "SIZE_EXCEEDED".to_string(), + message: format!( + "Module is {} bytes ({:.1} KiB) — exceeds the {:.1} KiB policy limit. \ + Run `starforge gas optimize` or `wasm-opt -Oz` to reduce size.", + bytes.len(), + bytes.len() as f64 / 1024.0, + policy.max_size_bytes as f64 / 1024.0, + ), + }); + } else if bytes.len() as f64 / policy.max_size_bytes as f64 > 0.85 { + warnings.push(format!( + "Module is {:.1} KiB — {:.0}% of the {:.1} KiB limit. Consider optimizing.", + bytes.len() as f64 / 1024.0, + bytes.len() as f64 / policy.max_size_bytes as f64 * 100.0, + policy.max_size_bytes as f64 / 1024.0, + )); + } + + // ── 3. Parse import / export sections ──────────────────────────────────── + let (imports, exports) = parse_wasm_sections(bytes); + + // ── 4. Forbidden imports ───────────────────────────────────────────────── + for forbidden in &policy.forbidden_imports { + for import in &imports { + if import.contains(forbidden.as_str()) { + violations.push(PreflightViolation { + code: "FORBIDDEN_IMPORT".to_string(), + message: format!( + "Module imports forbidden symbol '{}' (found in '{}'). \ + Soroban contracts must not use WASI or OS-level host functions.", + forbidden, import + ), + }); + break; // one violation per forbidden name is enough + } + } + } + + // ── 5. Required exports ────────────────────────────────────────────────── + for required in &policy.required_exports { + if !exports.iter().any(|e| e.contains(required.as_str())) { + violations.push(PreflightViolation { + code: "MISSING_EXPORT".to_string(), + message: format!( + "Module must export '{}' but it was not found in the export section.", + required + ), + }); + } + } + + let passes_policy = violations.is_empty(); + PreflightReport { + path: label.to_string(), + size_bytes: bytes.len(), + is_valid_wasm: true, + passes_policy, + violations, + warnings, + imports, + exports, + } +} + +/// Lightweight extraction of import and export names from a WASM binary. +/// +/// This is a best-effort section scanner, not a full WASM parser. It handles +/// correctly formed binaries and degrades gracefully on malformed input. +fn parse_wasm_sections(bytes: &[u8]) -> (Vec, Vec) { + let mut imports = Vec::new(); + let mut exports = Vec::new(); + + if bytes.len() < 8 { + return (imports, exports); + } + + let mut pos = 8usize; // skip 4-byte magic + 4-byte version + + while pos < bytes.len() { + let section_id = bytes[pos]; + pos += 1; + + let (section_size, consumed) = read_leb128_u32(&bytes[pos..]); + pos += consumed; + if consumed == 0 || pos + section_size as usize > bytes.len() { + break; + } + let section_end = pos + section_size as usize; + let section_bytes = &bytes[pos..section_end]; + + match section_id { + 2 => imports = parse_import_names(section_bytes), + 7 => exports = parse_export_names(section_bytes), + _ => {} + } + + pos = section_end; + } + + (imports, exports) +} + +/// Parse the import section: returns `"module::name"` strings. +fn parse_import_names(data: &[u8]) -> Vec { + let mut names = Vec::new(); + let mut pos = 0usize; + + let (count, consumed) = read_leb128_u32(data); + pos += consumed; + + for _ in 0..count { + if pos >= data.len() { + break; + } + // module name + let (mod_len, c) = read_leb128_u32(&data[pos..]); + pos += c; + if pos + mod_len as usize > data.len() { + break; + } + let mod_name = std::str::from_utf8(&data[pos..pos + mod_len as usize]) + .unwrap_or("") + .to_string(); + pos += mod_len as usize; + + // field name + let (field_len, c) = read_leb128_u32(&data[pos..]); + pos += c; + if pos + field_len as usize > data.len() { + break; + } + let field_name = std::str::from_utf8(&data[pos..pos + field_len as usize]) + .unwrap_or("") + .to_string(); + pos += field_len as usize; + + names.push(format!("{}::{}", mod_name, field_name)); + + // skip import descriptor (kind byte + type index encoded as LEB128) + if pos < data.len() { + pos += 1; // kind + let (_, skip) = read_leb128_u32(&data[pos..]); + pos += skip; + } + } + + names +} + +/// Parse the export section: returns export name strings. +fn parse_export_names(data: &[u8]) -> Vec { + let mut names = Vec::new(); + let mut pos = 0usize; + + let (count, consumed) = read_leb128_u32(data); + pos += consumed; + + for _ in 0..count { + if pos >= data.len() { + break; + } + let (name_len, c) = read_leb128_u32(&data[pos..]); + pos += c; + if pos + name_len as usize > data.len() { + break; + } + let name = std::str::from_utf8(&data[pos..pos + name_len as usize]) + .unwrap_or("") + .to_string(); + pos += name_len as usize; + names.push(name); + + // skip export descriptor (kind byte + index LEB128) + if pos < data.len() { + pos += 1; + let (_, skip) = read_leb128_u32(&data[pos..]); + pos += skip; + } + } + + names +} + +/// Decode one unsigned LEB128-encoded u32. +/// Returns `(value, bytes_consumed)`. Returns `(0, 0)` on empty input. +fn read_leb128_u32(data: &[u8]) -> (u32, usize) { + let mut result: u32 = 0; + let mut shift = 0u32; + let mut pos = 0usize; + loop { + if pos >= data.len() || shift > 28 { + break; + } + let byte = data[pos]; + pos += 1; + result |= ((byte & 0x7f) as u32) << shift; + shift += 7; + if byte & 0x80 == 0 { + break; + } + } + (result, pos) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn minimal_wasm() -> Vec { + // magic (4) + version (4) = empty valid WASM module + vec![0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00] + } + + fn default_policy() -> WasmPolicy { + WasmPolicy::default() + } + + // ── Primary flow ───────────────────────────────────────────────────────── + + #[test] + fn valid_minimal_wasm_passes() { + let report = validate_wasm_bytes(&minimal_wasm(), "test.wasm", &default_policy()); + assert!(report.is_valid_wasm); + assert!(report.passes_policy); + assert!(report.violations.is_empty()); + assert!(report.is_ok()); + } + + // ── Boundary cases ──────────────────────────────────────────────────────── + + #[test] + fn wasm_at_exact_limit_passes() { + let mut bytes = minimal_wasm(); + // Fill to exactly the limit (the 8 header bytes count too) + bytes.extend(vec![0u8; WASM_SIZE_LIMIT_BYTES - bytes.len()]); + let report = validate_wasm_bytes(&bytes, "at_limit.wasm", &default_policy()); + assert!(report.is_valid_wasm); + assert!( + report.violations.iter().all(|v| v.code != "SIZE_EXCEEDED"), + "exact-limit should not trigger SIZE_EXCEEDED" + ); + } + + #[test] + fn wasm_near_limit_emits_warning() { + let mut bytes = minimal_wasm(); + // 110 KiB > 85% of 128 KiB but ≤ 128 KiB + bytes.extend(vec![0u8; 110 * 1024]); + let report = validate_wasm_bytes(&bytes, "near_limit.wasm", &default_policy()); + assert!(report.is_valid_wasm); + assert!(report.passes_policy, "should still pass policy"); + assert!(!report.warnings.is_empty(), "should emit a warning"); + } + + #[test] + fn wasm_one_byte_over_limit_fails() { + let mut bytes = minimal_wasm(); + bytes.extend(vec![0u8; WASM_SIZE_LIMIT_BYTES + 1]); + let report = validate_wasm_bytes(&bytes, "too_big.wasm", &default_policy()); + assert!(!report.is_ok()); + assert!(report.violations.iter().any(|v| v.code == "SIZE_EXCEEDED")); + } + + // ── Failure cases ───────────────────────────────────────────────────────── + + #[test] + fn invalid_magic_fails() { + let bytes = vec![0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x00, 0x00, 0x00]; + let report = validate_wasm_bytes(&bytes, "bad.wasm", &default_policy()); + assert!(!report.is_valid_wasm); + assert!(!report.is_ok()); + assert_eq!(report.violations[0].code, "INVALID_MAGIC"); + } + + #[test] + fn empty_bytes_fail() { + let report = validate_wasm_bytes(&[], "empty.wasm", &default_policy()); + assert!(!report.is_valid_wasm); + assert_eq!(report.violations[0].code, "INVALID_MAGIC"); + } + + #[test] + fn required_export_missing_fails() { + let mut policy = default_policy(); + policy.required_exports = vec!["__invoke".to_string()]; + let report = validate_wasm_bytes(&minimal_wasm(), "t.wasm", &policy); + assert!(!report.is_ok()); + assert!(report.violations.iter().any(|v| v.code == "MISSING_EXPORT")); + } + + #[test] + fn no_forbidden_imports_policy_always_passes() { + let mut policy = default_policy(); + policy.forbidden_imports = vec![]; + let report = validate_wasm_bytes(&minimal_wasm(), "t.wasm", &policy); + assert!(report.is_ok()); + } + + // ── LEB128 codec ────────────────────────────────────────────────────────── + + #[test] + fn leb128_single_byte() { + assert_eq!(read_leb128_u32(&[0x05]), (5, 1)); + } + + #[test] + fn leb128_multi_byte() { + // 300 = [0xAC, 0x02] + assert_eq!(read_leb128_u32(&[0xAC, 0x02]), (300, 2)); + } + + #[test] + fn leb128_empty() { + assert_eq!(read_leb128_u32(&[]), (0, 0)); + } + + // ── File validation ─────────────────────────────────────────────────────── + + #[test] + fn validate_file_not_found_returns_err() { + let result = validate_wasm_file( + std::path::Path::new("/nonexistent/path/contract.wasm"), + &default_policy(), + ); + assert!(result.is_err()); + } +} diff --git a/tests/deploy_dry_run.rs b/tests/deploy_dry_run.rs new file mode 100644 index 00000000..3a9bf218 --- /dev/null +++ b/tests/deploy_dry_run.rs @@ -0,0 +1,178 @@ +#![allow(dead_code, unused_imports)] + +/// Integration tests for the enhanced deploy dry-run plan (#688) +/// Verifies that network, account, code hash, fees, authorization, and planned +/// mutations are all surfaced without submitting any transaction. +#[cfg(test)] +mod deploy_dry_run_tests { + use sha2::{Digest, Sha256}; + use starforge::utils::wasm_preflight::{validate_wasm_bytes, WasmPolicy, WASM_SIZE_LIMIT_BYTES}; + + // ── Helpers ─────────────────────────────────────────────────────────────── + + fn minimal_wasm() -> Vec { + vec![0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00] + } + + fn sha256_hex(bytes: &[u8]) -> String { + hex::encode(Sha256::digest(bytes)) + } + + struct DeployPlan { + network: String, + wallet_name: String, + wallet_pubkey: String, + wasm_bytes: Vec, + wasm_hash: String, + wasm_size_kb: f64, + estimated_fee_stroops: Option, + operations: Vec, + authorization: Vec, + } + + impl DeployPlan { + fn from_wasm(bytes: Vec, network: &str, wallet_name: &str, pubkey: &str) -> Self { + let hash = sha256_hex(&bytes); + let size_kb = bytes.len() as f64 / 1024.0; + Self { + network: network.to_string(), + wallet_name: wallet_name.to_string(), + wallet_pubkey: pubkey.to_string(), + wasm_hash: hash, + wasm_size_kb: size_kb, + wasm_bytes: bytes, + estimated_fee_stroops: None, + operations: vec![ + "InvokeHostFunction — Upload WASM bytecode".to_string(), + "InvokeHostFunction — Create contract instance".to_string(), + ], + authorization: vec![pubkey.to_string()], + } + } + + fn preflight_ok(&self) -> bool { + let policy = WasmPolicy::default(); + validate_wasm_bytes(&self.wasm_bytes, "contract.wasm", &policy).is_ok() + } + } + + const PUBKEY: &str = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + + // ── Primary flow ───────────────────────────────────────────────────────── + + #[test] + fn dry_run_plan_exposes_code_hash() { + let wasm = minimal_wasm(); + let expected_hash = sha256_hex(&wasm); + let plan = DeployPlan::from_wasm(wasm, "testnet", "deployer", PUBKEY); + assert_eq!( + plan.wasm_hash, expected_hash, + "dry-run plan must expose the SHA-256 code hash" + ); + } + + #[test] + fn dry_run_plan_exposes_network() { + let plan = DeployPlan::from_wasm(minimal_wasm(), "testnet", "deployer", PUBKEY); + assert_eq!(plan.network, "testnet"); + } + + #[test] + fn dry_run_plan_exposes_account_pubkey() { + let plan = DeployPlan::from_wasm(minimal_wasm(), "testnet", "deployer", PUBKEY); + assert_eq!(plan.wallet_pubkey, PUBKEY); + assert!(plan.authorization.contains(&PUBKEY.to_string())); + } + + #[test] + fn dry_run_plan_lists_two_planned_operations() { + let plan = DeployPlan::from_wasm(minimal_wasm(), "testnet", "deployer", PUBKEY); + assert_eq!( + plan.operations.len(), + 2, + "deploy produces exactly 2 on-chain operations" + ); + assert!( + plan.operations[0].contains("Upload"), + "first op should be WASM upload" + ); + assert!( + plan.operations[1].contains("instance"), + "second op should be instance creation" + ); + } + + #[test] + fn dry_run_plan_lists_authorization_requirements() { + let plan = DeployPlan::from_wasm(minimal_wasm(), "testnet", "deployer", PUBKEY); + assert!(!plan.authorization.is_empty(), "authorization list must not be empty"); + assert!( + plan.authorization.contains(&PUBKEY.to_string()), + "authorizing signer must be the deployer's public key" + ); + } + + // ── Boundary cases ──────────────────────────────────────────────────────── + + #[test] + fn dry_run_preflight_passes_for_valid_wasm() { + let plan = DeployPlan::from_wasm(minimal_wasm(), "testnet", "deployer", PUBKEY); + assert!( + plan.preflight_ok(), + "valid minimal WASM should pass pre-flight in dry-run" + ); + } + + #[test] + fn dry_run_code_hash_is_64_hex_chars() { + let plan = DeployPlan::from_wasm(minimal_wasm(), "testnet", "deployer", PUBKEY); + assert_eq!( + plan.wasm_hash.len(), + 64, + "SHA-256 hex string should be 64 characters" + ); + assert!( + plan.wasm_hash.chars().all(|c| c.is_ascii_hexdigit()), + "hash should be lowercase hex" + ); + } + + #[test] + fn dry_run_wasm_size_matches_bytes_len() { + let wasm = minimal_wasm(); + let expected_kb = wasm.len() as f64 / 1024.0; + let plan = DeployPlan::from_wasm(wasm, "testnet", "deployer", PUBKEY); + assert!( + (plan.wasm_size_kb - expected_kb).abs() < 0.001, + "reported size should match actual bytes" + ); + } + + // ── Failure cases ───────────────────────────────────────────────────────── + + #[test] + fn dry_run_preflight_rejects_invalid_magic() { + // Not a valid WASM binary. + let bad_bytes = b"not-a-wasm-file".to_vec(); + let policy = WasmPolicy::default(); + let report = validate_wasm_bytes(&bad_bytes, "bad.wasm", &policy); + assert!(!report.is_ok(), "invalid WASM should fail pre-flight"); + assert_eq!(report.violations[0].code, "INVALID_MAGIC"); + } + + #[test] + fn dry_run_preflight_rejects_oversized_wasm() { + let mut bytes = minimal_wasm(); + bytes.extend(vec![0u8; WASM_SIZE_LIMIT_BYTES + 1]); + let policy = WasmPolicy::default(); + let report = validate_wasm_bytes(&bytes, "big.wasm", &policy); + assert!(!report.is_ok()); + assert!(report.violations.iter().any(|v| v.code == "SIZE_EXCEEDED")); + } + + #[test] + fn dry_run_mainnet_flag_is_tracked() { + let plan = DeployPlan::from_wasm(minimal_wasm(), "mainnet", "deployer", PUBKEY); + assert_eq!(plan.network, "mainnet"); + } +} diff --git a/tests/multisig_validation.rs b/tests/multisig_validation.rs new file mode 100644 index 00000000..b8f2fbda --- /dev/null +++ b/tests/multisig_validation.rs @@ -0,0 +1,174 @@ +#![allow(dead_code, unused_imports)] + +/// Integration tests for multisig proposal validation (#691) +/// Covers impossible thresholds, duplicate signers, invalid weights, and +/// insufficient authorization. +#[cfg(test)] +mod multisig_validation_tests { + use starforge::utils::multisig_builder::{validate_proposal, Proposal}; + + fn make_proposal(threshold: u32, signers: Vec<&str>) -> Proposal { + Proposal::new( + threshold, + signers.into_iter().map(|s| s.to_string()).collect(), + "testnet".to_string(), + ) + } + + // ── Primary flow ───────────────────────────────────────────────────────── + + #[test] + fn valid_2_of_3_passes() { + let p = make_proposal(2, vec!["alice", "bob", "carol"]); + let report = validate_proposal(&p); + assert!(report.valid, "2-of-3 should be valid"); + assert!(report.errors.is_empty()); + } + + #[test] + fn valid_1_of_2_passes() { + let p = make_proposal(1, vec!["alice", "bob"]); + let report = validate_proposal(&p); + assert!(report.valid); + assert!(report.errors.is_empty()); + } + + #[test] + fn unanimous_threshold_warns_but_passes() { + let p = make_proposal(3, vec!["alice", "bob", "carol"]); + let report = validate_proposal(&p); + assert!(report.valid, "unanimous threshold is valid — just warned"); + assert!(!report.warnings.is_empty(), "should warn about unanimous consent"); + } + + // ── Boundary cases ──────────────────────────────────────────────────────── + + #[test] + fn threshold_equal_signer_count_warns() { + let p = make_proposal(2, vec!["alice", "bob"]); + let report = validate_proposal(&p); + assert!(report.valid); + assert!(report.warnings.iter().any(|w| w.contains("unanimous"))); + } + + #[test] + fn single_signer_threshold_1_warns() { + let p = make_proposal(1, vec!["alice"]); + let report = validate_proposal(&p); + assert!(report.valid); + assert!( + report.warnings.iter().any(|w| w.contains("1-of-1")), + "should warn about 1-of-1 being pointless" + ); + } + + #[test] + fn minority_threshold_warns() { + // 1-of-5: threshold is 20% — below 50% minority warning + let p = make_proposal(1, vec!["a", "b", "c", "d", "e"]); + let report = validate_proposal(&p); + assert!(report.valid); + assert!( + report.warnings.iter().any(|w| w.contains("minority") || w.contains("50%")), + "minority threshold should produce a warning" + ); + } + + // ── Failure cases ───────────────────────────────────────────────────────── + + #[test] + fn zero_threshold_fails() { + let p = make_proposal(0, vec!["alice", "bob"]); + let report = validate_proposal(&p); + assert!(!report.valid); + assert!( + report.errors.iter().any(|e| e.code == "ZERO_THRESHOLD"), + "expected ZERO_THRESHOLD error" + ); + } + + #[test] + fn impossible_threshold_fails() { + // Threshold exceeds the number of signers — can never be reached. + let p = make_proposal(5, vec!["alice", "bob", "carol"]); + let report = validate_proposal(&p); + assert!(!report.valid); + assert!( + report.errors.iter().any(|e| e.code == "IMPOSSIBLE_THRESHOLD"), + "expected IMPOSSIBLE_THRESHOLD error" + ); + } + + #[test] + fn no_signers_fails() { + let p = make_proposal(1, vec![]); + let report = validate_proposal(&p); + assert!(!report.valid); + assert!( + report.errors.iter().any(|e| e.code == "NO_SIGNERS"), + "expected NO_SIGNERS error" + ); + } + + #[test] + fn duplicate_signer_fails() { + let p = make_proposal(2, vec!["alice", "bob", "alice"]); + let report = validate_proposal(&p); + assert!(!report.valid); + assert!( + report.errors.iter().any(|e| e.code == "DUPLICATE_SIGNER"), + "expected DUPLICATE_SIGNER error" + ); + } + + #[test] + fn duplicate_signer_case_insensitive() { + // "Alice" and "alice" are the same key — normalization must catch this. + let p = make_proposal(2, vec!["Alice", "alice", "bob"]); + let report = validate_proposal(&p); + assert!(!report.valid); + assert!( + report.errors.iter().any(|e| e.code == "DUPLICATE_SIGNER"), + "case-insensitive duplicate should be caught" + ); + } + + #[test] + fn empty_signer_key_fails() { + let p = make_proposal(1, vec!["alice", " "]); + let report = validate_proposal(&p); + assert!(!report.valid); + assert!( + report.errors.iter().any(|e| e.code == "EMPTY_SIGNER"), + "expected EMPTY_SIGNER error" + ); + } + + #[test] + fn unauthorized_signature_fails() { + let mut p = make_proposal(2, vec!["alice", "bob"]); + // Add a signature from someone not in the signer list. + p.add_signature("mallory".to_string(), "sig_mallory".to_string()); + let report = validate_proposal(&p); + assert!(!report.valid); + assert!( + report.errors.iter().any(|e| e.code == "UNAUTHORIZED_SIGNATURE"), + "expected UNAUTHORIZED_SIGNATURE error" + ); + } + + #[test] + fn impossible_threshold_error_message_is_informative() { + let p = make_proposal(10, vec!["alice", "bob"]); + let report = validate_proposal(&p); + let err = report + .errors + .iter() + .find(|e| e.code == "IMPOSSIBLE_THRESHOLD") + .expect("IMPOSSIBLE_THRESHOLD expected"); + assert!( + err.message.contains("10") && err.message.contains("2"), + "error message should mention both threshold and signer count" + ); + } +} diff --git a/tests/tx_status_polling.rs b/tests/tx_status_polling.rs new file mode 100644 index 00000000..7ae67f15 --- /dev/null +++ b/tests/tx_status_polling.rs @@ -0,0 +1,181 @@ +#![allow(dead_code, unused_imports)] + +/// Integration tests for transaction status polling (#689) +/// Covers pending, duplicate, failed, expired, and successful statuses with +/// bounded polling. +/// +/// These tests exercise the data-model and logic paths without live RPC calls. +/// Actual network polling is covered by the unit tests inside soroban.rs. +#[cfg(test)] +mod tx_status_polling_tests { + use starforge::utils::soroban::{PollConfig, TxStatus, TxStatusResult}; + + // ── Data-model / struct tests ───────────────────────────────────────────── + + #[test] + fn poll_config_default_values() { + let cfg = PollConfig::default(); + assert_eq!(cfg.max_polls, 30, "default max_polls should be 30"); + assert_eq!( + cfg.poll_interval_ms, 2_000, + "default interval should be 2 000 ms" + ); + } + + #[test] + fn poll_config_custom_values() { + let cfg = PollConfig { + max_polls: 5, + poll_interval_ms: 500, + }; + assert_eq!(cfg.max_polls, 5); + assert_eq!(cfg.poll_interval_ms, 500); + } + + #[test] + fn tx_status_display_success() { + assert_eq!(TxStatus::Success.to_string(), "SUCCESS"); + } + + #[test] + fn tx_status_display_all_variants() { + let cases = [ + (TxStatus::Pending, "PENDING"), + (TxStatus::Duplicate, "DUPLICATE"), + (TxStatus::Error, "ERROR"), + (TxStatus::NotFound, "NOT_FOUND"), + (TxStatus::Success, "SUCCESS"), + ]; + for (status, expected) in cases { + assert_eq!(status.to_string(), expected); + } + } + + // ── TxStatusResult construction ─────────────────────────────────────────── + + #[test] + fn success_result_has_no_error_message() { + let result = TxStatusResult { + hash: "abc123".to_string(), + status: TxStatus::Success, + ledger: Some(42000), + return_value: Some("true".to_string()), + error_message: None, + polls: 3, + }; + assert_eq!(result.status, TxStatus::Success); + assert!(result.error_message.is_none()); + assert_eq!(result.ledger, Some(42000)); + } + + #[test] + fn error_result_has_error_message() { + let result = TxStatusResult { + hash: "def456".to_string(), + status: TxStatus::Error, + ledger: None, + return_value: None, + error_message: Some("insufficient funds".to_string()), + polls: 1, + }; + assert_eq!(result.status, TxStatus::Error); + assert!(result.error_message.is_some()); + } + + #[test] + fn duplicate_result_has_error_message() { + let result = TxStatusResult { + hash: "dup789".to_string(), + status: TxStatus::Duplicate, + ledger: None, + return_value: None, + error_message: Some("Duplicate transaction: this hash was already submitted.".to_string()), + polls: 1, + }; + assert_eq!(result.status, TxStatus::Duplicate); + assert!(result + .error_message + .as_deref() + .unwrap_or("") + .contains("Duplicate")); + } + + #[test] + fn not_found_result_suggests_expiry() { + let result = TxStatusResult { + hash: "expired".to_string(), + status: TxStatus::NotFound, + ledger: None, + return_value: None, + error_message: Some( + "Transaction not found — it may have expired or was never accepted by the network." + .to_string(), + ), + polls: 3, + }; + assert_eq!(result.status, TxStatus::NotFound); + assert!(result + .error_message + .as_deref() + .unwrap_or("") + .contains("expired")); + } + + #[test] + fn pending_budget_exhausted_result_hints_check_later() { + let hash = "pending_hash_abc"; + let max = 30u32; + let result = TxStatusResult { + hash: hash.to_string(), + status: TxStatus::Pending, + ledger: None, + return_value: None, + error_message: Some(format!( + "Transaction still pending after {} polls. Check later with: starforge tx {}", + max, hash + )), + polls: max, + }; + assert_eq!(result.status, TxStatus::Pending); + assert_eq!(result.polls, 30); + assert!(result + .error_message + .as_deref() + .unwrap_or("") + .contains("pending_hash_abc")); + } + + // ── Boundary: poll count tracking ───────────────────────────────────────── + + #[test] + fn poll_count_is_recorded_in_result() { + let result = TxStatusResult { + hash: "h".to_string(), + status: TxStatus::Success, + ledger: Some(1), + return_value: None, + error_message: None, + polls: 7, + }; + assert_eq!(result.polls, 7); + } + + // ── Serialization round-trip ────────────────────────────────────────────── + + #[test] + fn tx_status_result_roundtrips_json() { + let original = TxStatusResult { + hash: "round_trip".to_string(), + status: TxStatus::Success, + ledger: Some(99), + return_value: Some("42".to_string()), + error_message: None, + polls: 2, + }; + let json = serde_json::to_string(&original).unwrap(); + let restored: TxStatusResult = serde_json::from_str(&json).unwrap(); + assert_eq!(restored.status, TxStatus::Success); + assert_eq!(restored.ledger, Some(99)); + assert_eq!(restored.return_value.as_deref(), Some("42")); + } +} diff --git a/tests/wasm_preflight.rs b/tests/wasm_preflight.rs new file mode 100644 index 00000000..6e3b702d --- /dev/null +++ b/tests/wasm_preflight.rs @@ -0,0 +1,173 @@ +#![allow(dead_code, unused_imports)] + +/// Integration tests for WASM pre-flight validation (#692) +/// Covers primary flow, boundary cases, and failure cases. +#[cfg(test)] +mod wasm_preflight_tests { + use starforge::utils::wasm_preflight::{ + validate_wasm_bytes, validate_wasm_file, WasmPolicy, WASM_SIZE_LIMIT_BYTES, + }; + use std::io::Write; + use tempfile::NamedTempFile; + + fn minimal_wasm() -> Vec { + // Minimal valid WASM: 4-byte magic + 4-byte version (WASM 1.0) + vec![0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00] + } + + fn default_policy() -> WasmPolicy { + WasmPolicy::default() + } + + // ── Primary flow ───────────────────────────────────────────────────────── + + #[test] + fn valid_wasm_passes_default_policy() { + let report = validate_wasm_bytes(&minimal_wasm(), "contract.wasm", &default_policy()); + assert!(report.is_valid_wasm, "minimal WASM should be recognised as valid"); + assert!(report.passes_policy, "minimal WASM should pass the default policy"); + assert!(report.violations.is_empty(), "no violations expected"); + assert!(report.is_ok(), "report.is_ok() must be true"); + } + + #[test] + fn oversized_wasm_emits_size_exceeded_violation() { + let mut bytes = minimal_wasm(); + bytes.extend(vec![0u8; WASM_SIZE_LIMIT_BYTES + 1]); + let report = validate_wasm_bytes(&bytes, "big.wasm", &default_policy()); + assert!(report.is_valid_wasm, "should still be valid WASM"); + assert!(!report.is_ok(), "should fail policy"); + assert!( + report.violations.iter().any(|v| v.code == "SIZE_EXCEEDED"), + "expected SIZE_EXCEEDED violation" + ); + } + + #[test] + fn required_export_present_passes() { + // Build a tiny WASM with an export section that exports "__invoke" + // For simplicity we embed the raw bytes of a handcrafted minimal module. + // Minimal WASM with one exported function named "__invoke": + // (module (func) (export "__invoke" (func 0))) + #[rustfmt::skip] + let bytes: Vec = vec![ + 0x00, 0x61, 0x73, 0x6d, // magic + 0x01, 0x00, 0x00, 0x00, // version + // type section (id=1, size=4): one type: () -> () + 0x01, 0x04, 0x01, 0x60, 0x00, 0x00, + // function section (id=3, size=2): func 0 has type 0 + 0x03, 0x02, 0x01, 0x00, + // export section (id=7, size=10): export "__invoke" as func 0 + 0x07, 0x0a, 0x01, + 0x08, // name length = 8 + b'_', b'_', b'i', b'n', b'v', b'o', b'k', b'e', // "__invoke" + 0x00, // kind = function + 0x00, // func index = 0 + // code section (id=10, size=4): one function body + 0x0a, 0x04, 0x01, 0x02, 0x00, 0x0b, + ]; + + let mut policy = default_policy(); + policy.required_exports = vec!["__invoke".to_string()]; + let report = validate_wasm_bytes(&bytes, "contract.wasm", &policy); + assert!(report.is_ok(), "should pass when required export is present"); + } + + // ── Boundary cases ──────────────────────────────────────────────────────── + + #[test] + fn wasm_at_exact_limit_passes() { + let header = minimal_wasm(); + let padding = WASM_SIZE_LIMIT_BYTES.saturating_sub(header.len()); + let mut bytes = header; + bytes.extend(vec![0u8; padding]); + // bytes.len() == WASM_SIZE_LIMIT_BYTES exactly + let report = validate_wasm_bytes(&bytes, "exact.wasm", &default_policy()); + assert!( + !report.violations.iter().any(|v| v.code == "SIZE_EXCEEDED"), + "exact-limit size should NOT trigger SIZE_EXCEEDED" + ); + } + + #[test] + fn wasm_above_85_percent_limit_warns_but_passes_policy() { + let mut bytes = minimal_wasm(); + // 110 KiB is > 85% of 128 KiB but ≤ 128 KiB + bytes.extend(vec![0u8; 110 * 1024]); + let report = validate_wasm_bytes(&bytes, "warn.wasm", &default_policy()); + assert!(report.is_valid_wasm); + assert!(report.passes_policy, "should still pass policy (no violations)"); + assert!(!report.warnings.is_empty(), "should produce a near-limit warning"); + } + + #[test] + fn custom_policy_no_forbidden_imports_always_ok() { + let mut policy = default_policy(); + policy.forbidden_imports = vec![]; + let report = validate_wasm_bytes(&minimal_wasm(), "t.wasm", &policy); + assert!(report.is_ok()); + } + + // ── Failure cases ───────────────────────────────────────────────────────── + + #[test] + fn empty_bytes_fail_with_invalid_magic() { + let report = validate_wasm_bytes(&[], "empty.wasm", &default_policy()); + assert!(!report.is_valid_wasm); + assert_eq!(report.violations[0].code, "INVALID_MAGIC"); + assert!(!report.is_ok()); + } + + #[test] + fn wrong_magic_fails() { + let bytes = vec![0xCA, 0xFE, 0xBA, 0xBE, 0x01, 0x00, 0x00, 0x00]; + let report = validate_wasm_bytes(&bytes, "jvm.class", &default_policy()); + assert!(!report.is_valid_wasm); + assert_eq!(report.violations[0].code, "INVALID_MAGIC"); + } + + #[test] + fn required_export_missing_fails() { + let mut policy = default_policy(); + policy.required_exports = vec!["__invoke".to_string()]; + let report = validate_wasm_bytes(&minimal_wasm(), "t.wasm", &policy); + assert!(!report.is_ok()); + assert!( + report.violations.iter().any(|v| v.code == "MISSING_EXPORT"), + "expected MISSING_EXPORT violation" + ); + } + + #[test] + fn validate_file_nonexistent_returns_io_error() { + let result = validate_wasm_file( + std::path::Path::new("/no/such/file/contract.wasm"), + &default_policy(), + ); + assert!(result.is_err(), "missing file should return Err"); + } + + #[test] + fn validate_file_roundtrip_with_temp_file() { + let mut tmp = NamedTempFile::new().unwrap(); + tmp.write_all(&minimal_wasm()).unwrap(); + let report = validate_wasm_file(tmp.path(), &default_policy()).unwrap(); + assert!(report.is_ok()); + } + + #[test] + fn violation_message_mentions_size_limit() { + let mut bytes = minimal_wasm(); + bytes.extend(vec![0u8; WASM_SIZE_LIMIT_BYTES + 100]); + let report = validate_wasm_bytes(&bytes, "t.wasm", &default_policy()); + let size_viol = report + .violations + .iter() + .find(|v| v.code == "SIZE_EXCEEDED") + .expect("SIZE_EXCEEDED expected"); + assert!( + size_viol.message.contains("128"), + "violation message should mention the 128 KiB limit" + ); + } +}