From 72c4c8035972c3842b8839e29410d88e92f7b251 Mon Sep 17 00:00:00 2001 From: shamoo53 Date: Fri, 23 Jan 2026 15:50:18 +0100 Subject: [PATCH] Soroban-Contract-Parser-Integration Soroban-Contract-Parser-Integration --- SOROBAN_INTEGRATION.md | 291 ++++++++ apps/api/src/validation/analysis.validator.ts | 2 +- examples/soroban_demo_contract.rs | 164 +++++ libs/engine/analyzers/rust-analyzer.ts | 9 +- libs/engine/core/analyzer-interface.ts | 1 + libs/engine/src/scanner.rs | 97 ++- packages/rules/src/lib.rs | 2 + packages/rules/src/rule_engine.rs | 3 + packages/rules/src/soroban/analyzer.rs | 468 +++++++++++++ packages/rules/src/soroban/mod.rs | 128 ++++ packages/rules/src/soroban/parser.rs | 520 ++++++++++++++ packages/rules/src/soroban/rule_engine.rs | 654 ++++++++++++++++++ packages/rules/src/soroban/tests.rs | 187 +++++ tests/integration_tests.rs | 236 +++++++ 14 files changed, 2752 insertions(+), 10 deletions(-) create mode 100644 SOROBAN_INTEGRATION.md create mode 100644 examples/soroban_demo_contract.rs create mode 100644 packages/rules/src/soroban/analyzer.rs create mode 100644 packages/rules/src/soroban/mod.rs create mode 100644 packages/rules/src/soroban/parser.rs create mode 100644 packages/rules/src/soroban/rule_engine.rs create mode 100644 packages/rules/src/soroban/tests.rs diff --git a/SOROBAN_INTEGRATION.md b/SOROBAN_INTEGRATION.md new file mode 100644 index 0000000..90ae444 --- /dev/null +++ b/SOROBAN_INTEGRATION.md @@ -0,0 +1,291 @@ +# Soroban Contract Parser Integration + +This document describes the implementation of Soroban contract parsing and analysis capabilities in GasGuard. + +## Overview + +GasGuard now supports comprehensive static analysis of Soroban smart contracts built on the Stellar network. The implementation includes: + +- AST-based parsing of Soroban-specific macros (`#[contract]`, `#[contractimpl]`, `#[contracttype]`) +- Rule-based analysis for gas optimization and security issues +- Integration with existing GasGuard infrastructure + +## Key Components + +### 1. Soroban AST Structures (`packages/rules/src/soroban/mod.rs`) + +Core data structures representing Soroban contract elements: + +```rust +pub struct SorobanContract { + pub name: String, + pub contract_types: Vec, + pub implementations: Vec, + pub source: String, + pub file_path: String, +} + +pub struct SorobanStruct { + pub name: String, + pub fields: Vec, + pub line_number: usize, + pub raw_definition: String, +} + +pub struct SorobanImpl { + pub target: String, + pub functions: Vec, + pub line_number: usize, + pub raw_definition: String, +} +``` + +### 2. Soroban Parser (`packages/rules/src/soroban/parser.rs`) + +The parser extracts AST-like structures from Soroban contract source code: + +```rust +pub struct SorobanParser; + +impl SorobanParser { + pub fn parse_contract(source: &str, file_path: &str) -> SorobanResult { + // Implementation details... + } +} +``` + +Key parsing capabilities: +- Extract contract names from `#[contract]` attributes +- Parse `#[contracttype]` struct definitions +- Parse `#[contractimpl]` implementation blocks +- Extract function signatures and parameters +- Handle visibility modifiers and return types + +### 3. Soroban Analyzer (`packages/rules/src/soroban/analyzer.rs`) + +Performs static analysis on parsed Soroban contracts: + +```rust +pub struct SorobanAnalyzer; + +impl SorobanAnalyzer { + pub fn analyze_contract(contract: &SorobanContract) -> Vec { + // Implementation details... + } +} +``` + +Analysis capabilities include: +- Unused state variable detection +- Inefficient storage access patterns +- Unbounded loop detection +- Expensive string operation identification +- Missing error handling detection +- Inefficient integer type usage + +### 4. Soroban Rule Engine (`packages/rules/src/soroban/rule_engine.rs`) + +Specialized rule engine for Soroban contracts: + +```rust +pub struct SorobanRuleEngine { + rules: HashMap>, +} + +pub trait SorobanRule { + fn id(&self) -> &str; + fn name(&self) -> &str; + fn description(&self) -> &str; + fn severity(&self) -> ViolationSeverity; + fn apply(&self, contract: &SorobanContract) -> Vec; +} +``` + +Built-in rules: +- `UnusedStateVariablesRule` - Detects unused contract state +- `InefficientStorageAccessRule` - Identifies repeated storage operations +- `UnboundedLoopRule` - Flags potentially infinite loops +- `ExpensiveStringOperationsRule` - Detects costly string operations +- `MissingConstructorRule` - Ensures proper contract initialization +- `AdminPatternRule` - Suggests access control patterns +- `InefficientIntegerTypesRule` - Recommends optimal integer sizes +- `MissingErrorHandlingRule` - Enforces proper error handling + +### 5. Contract Scanner Integration (`libs/engine/src/scanner.rs`) + +Extended to support Soroban contract detection and analysis: + +```rust +pub enum Language { + Rust, + Vyper, + Soroban, // Added support +} + +impl Language { + pub fn from_content(content: &str) -> Option { + // Detect Soroban contracts by looking for soroban_sdk imports + // and Soroban-specific macros + } +} +``` + +### 6. TypeScript Validation Updates (`apps/api/src/validation/analysis.validator.ts`) + +Updated to recognize 'soroban' as a supported language: + +```typescript +private static readonly SUPPORTED_LANGUAGES = ['rust', 'typescript', 'javascript', 'solidity', 'soroban']; +``` + +### 7. Rust Analyzer Enhancements (`libs/engine/analyzers/rust-analyzer.ts`) + +Extended to handle Soroban contract patterns specifically: + +```typescript +supportsLanguage(language: Language | string): boolean { + return language === Language.RUST || + language === Language.SOROBAN || + language === 'rust' || + language === 'rs' || + language === 'soroban'; +} +``` + +## Usage Examples + +### Basic Soroban Contract Analysis + +```rust +use gasguard_rules::{SorobanParser, SorobanAnalyzer}; + +let contract_code = r#" +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env}; + +#[contracttype] +pub struct TokenContract { + pub admin: Address, + pub total_supply: u64, + pub balances: Map, +} + +#[contractimpl] +impl TokenContract { + pub fn new(admin: Address, initial_supply: u64) -> Self { + // Implementation + } + + pub fn transfer(from: Address, to: Address, amount: u64) { + // Implementation + } +} +"#; + +// Parse the contract +let contract = SorobanParser::parse_contract(contract_code, "token.rs")?; + +// Analyze for issues +let violations = SorobanAnalyzer::analyze_contract(&contract); + +for violation in violations { + println!("Issue: {} at line {}", violation.description, violation.line_number); +} +``` + +### Using the Rule Engine + +```rust +use gasguard_rules::SorobanRuleEngine; + +let engine = SorobanRuleEngine::with_default_rules(); +let violations = engine.analyze(contract_code, "contract.rs")?; + +println!("Found {} issues", violations.len()); +``` + +### Integration with Contract Scanner + +```rust +use gasguard_engine::ContractScanner; + +let scanner = ContractScanner::new(); + +// Automatic language detection +let result = scanner.scan_content(contract_code, "my_contract.rs".to_string())?; + +// Or direct Soroban analysis +let soroban_result = scanner.scan_soroban_content(contract_code, "my_contract.rs".to_string())?; +``` + +## Detected Issues and Recommendations + +The Soroban analyzer detects various issues with specific recommendations: + +### 1. Unused State Variables +**Detection**: Variables declared in contract structs but never referenced in functions +**Recommendation**: Remove unused variables to save ledger storage costs + +### 2. Inefficient Storage Access +**Detection**: Multiple reads/writes to the same storage key without caching +**Recommendation**: Cache storage values in local variables + +### 3. Unbounded Loops +**Detection**: Loops without clear termination conditions +**Recommendation**: Add bounds checking or use pagination patterns + +### 4. Expensive String Operations +**Detection**: Frequent `.to_string()` or `String::from()` calls +**Recommendation**: Use `Symbol` or `Bytes` for fixed data when possible + +### 5. Missing Error Handling +**Detection**: State-modifying functions that don't return `Result` +**Recommendation**: Return `Result<(), Error>` for proper error propagation + +### 6. Inefficient Integer Types +**Detection**: Use of `u128`/`i128` when smaller types would suffice +**Recommendation**: Use appropriate integer sizes (`u64`, `u32`, etc.) + +## Testing + +Comprehensive tests are included in: +- `tests/integration_tests.rs` - End-to-end integration tests +- `packages/rules/src/soroban/tests.rs` - Unit tests for parsing and analysis + +Run tests with: +```bash +cargo test +``` + +## API Integration + +The TypeScript API now accepts 'soroban' as a language parameter: + +```typescript +const response = await fetch('/api/analyze', { + method: 'POST', + body: JSON.stringify({ + code: sorobanContractCode, + filePath: 'contract.rs', + language: 'soroban' + }) +}); +``` + +## Future Enhancements + +Planned improvements: +- More sophisticated AST parsing using `syn` crate +- Cross-function analysis for better unused variable detection +- Integration with Soroban SDK documentation +- Performance optimization rules specific to Stellar's fee model +- Custom rule configuration for different project requirements + +## Contributing + +To add new Soroban-specific rules: +1. Implement a struct that implements the `SorobanRule` trait +2. Add it to the `SorobanRuleEngine::add_default_rules()` method +3. Include comprehensive tests +4. Update documentation + +The modular design makes it easy to extend with new analysis capabilities while maintaining compatibility with the existing GasGuard ecosystem. \ No newline at end of file diff --git a/apps/api/src/validation/analysis.validator.ts b/apps/api/src/validation/analysis.validator.ts index f4ea786..b2178e0 100644 --- a/apps/api/src/validation/analysis.validator.ts +++ b/apps/api/src/validation/analysis.validator.ts @@ -2,7 +2,7 @@ import { Request, Response, NextFunction } from 'express'; import { CodebaseSubmissionRequest, ValidationError } from '../schemas/analysis.schema'; export class AnalysisValidator { - private static readonly SUPPORTED_LANGUAGES = ['rust', 'typescript', 'javascript', 'solidity']; + private static readonly SUPPORTED_LANGUAGES = ['rust', 'typescript', 'javascript', 'solidity', 'soroban']; private static readonly SUPPORTED_FRAMEWORKS = ['soroban', 'solidity', 'general']; private static readonly MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB private static readonly MAX_FILES = 100; diff --git a/examples/soroban_demo_contract.rs b/examples/soroban_demo_contract.rs new file mode 100644 index 0000000..dfe6adc --- /dev/null +++ b/examples/soroban_demo_contract.rs @@ -0,0 +1,164 @@ +//! Example Soroban contract demonstrating various analysis scenarios +//! +//! This contract showcases different patterns that GasGuard's Soroban analyzer +//! can detect, including both good and problematic practices. + +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, Symbol, Map}; + +/// A token contract with various issues for demonstration purposes +#[contracttype] +pub struct DemoTokenContract { + pub admin: Address, + pub total_supply: u64, + pub balances: Map, + pub unused_counter: u128, // Issue: unused state variable + pub inefficient_field: String, // Issue: String instead of Symbol +} + +/// Another contract showing good practices +#[contracttype] +pub struct OptimizedContract { + pub owner: Address, + pub balance: u64, + pub transaction_count: u32, +} + +#[contractimpl] +impl DemoTokenContract { + /// Constructor with some issues + pub fn new(admin: Address, initial_supply: u64) -> Self { + let mut balances = Map::new(); + balances.set(admin, initial_supply); + + Self { + admin, + total_supply: initial_supply, + balances, + unused_counter: 0, // Never used + inefficient_field: "demo".to_string(), // Expensive String operation + } + } + + /// Transfer function with multiple issues + pub fn transfer(&mut self, from: Address, to: Address, amount: u64) { + // Issue: Multiple storage reads without caching + let from_balance = self.balances.get(from).unwrap_or(0); + let to_balance = self.balances.get(to).unwrap_or(0); + + // Issue: No error handling (should return Result) + self.balances.set(from, from_balance - amount); + self.balances.set(to, to_balance + amount); + } + + /// Function with unbounded loop + pub fn process_all_accounts(&self, accounts: Vec
) { + // Issue: Potentially unbounded loop + for account in accounts { + let balance = self.balances.get(account).unwrap_or(0); + // Process balance... + } + } + + /// Function with expensive operations + pub fn generate_report(&self) -> String { + // Issue: Multiple expensive string operations + let report = "Report: ".to_string(); + let total = self.total_supply.to_string(); + let admin_str = format!("Admin: {:?}", self.admin); + + format!("{}{}{}", report, total, admin_str) + } +} + +#[contractimpl] +impl OptimizedContract { + /// Well-structured constructor + pub fn new(owner: Address, initial_balance: u64) -> Result { + if initial_balance == 0 { + return Err(DemoError::InvalidAmount); + } + + Ok(Self { + owner, + balance: initial_balance, + transaction_count: 0, + }) + } + + /// Properly implemented transfer with error handling + pub fn transfer(&mut self, to: Address, amount: u64) -> Result<(), DemoError> { + if amount == 0 { + return Err(DemoError::InvalidAmount); + } + + if self.balance < amount { + return Err(DemoError::InsufficientBalance); + } + + // Cache storage value for efficiency + let current_balance = self.balance; + self.balance = current_balance - amount; + self.transaction_count += 1; + + Ok(()) + } + + /// Efficient getter + pub fn get_balance(&self) -> u64 { + self.balance + } +} + +/// Error type for the contract +#[contracttype] +#[derive(Debug, Clone)] +pub enum DemoError { + InvalidAmount, + InsufficientBalance, + Unauthorized, +} + +#[cfg(test)] +mod test { + use super::*; + use soroban_sdk::{vec, Env}; + + #[test] + fn test_demo_contract_analysis() { + let env = Env::default(); + let admin = Address::generate(&env); + + let mut contract = DemoTokenContract::new(admin, 1000); + + // This contract should trigger multiple GasGuard warnings: + // 1. Unused state variable (unused_counter) + // 2. Inefficient field type (String instead of Symbol) + // 3. Multiple storage accesses without caching + // 4. Missing error handling in transfer + // 5. Potentially unbounded loop + // 6. Expensive string operations + + let recipient = Address::generate(&env); + contract.transfer(admin, recipient, 100); + + assert_eq!(contract.balances.get(admin).unwrap_or(0), 900); + assert_eq!(contract.balances.get(recipient).unwrap_or(0), 100); + } + + #[test] + fn test_optimized_contract() { + let env = Env::default(); + let owner = Address::generate(&env); + + let mut contract = OptimizedContract::new(owner, 1000).unwrap(); + + // This contract should have minimal GasGuard warnings + // as it follows best practices + + let recipient = Address::generate(&env); + contract.transfer(recipient, 100).unwrap(); + + assert_eq!(contract.get_balance(), 900); + assert_eq!(contract.transaction_count, 1); + } +} \ No newline at end of file diff --git a/libs/engine/analyzers/rust-analyzer.ts b/libs/engine/analyzers/rust-analyzer.ts index 32b421f..0667242 100644 --- a/libs/engine/analyzers/rust-analyzer.ts +++ b/libs/engine/analyzers/rust-analyzer.ts @@ -98,11 +98,11 @@ export class RustAnalyzer extends BaseAnalyzer implements Analyzer { } supportsLanguage(language: Language | string): boolean { - return language === Language.RUST || language === 'rust' || language === 'rs'; + return language === Language.RUST || language === Language.SOROBAN || language === 'rust' || language === 'rs' || language === 'soroban'; } getSupportedLanguages(): Language[] { - return [Language.RUST]; + return [Language.RUST, Language.SOROBAN]; } getRules(): Rule[] { @@ -261,7 +261,10 @@ export class RustAnalyzer extends BaseAnalyzer implements Analyzer { private isSorobanContract(code: string): boolean { - return code.includes('soroban_sdk') || code.includes('#[contract]'); + return code.includes('soroban_sdk') || + code.includes('#[contract]') || + code.includes('#[contractimpl]') || + code.includes('#[contracttype]'); } diff --git a/libs/engine/core/analyzer-interface.ts b/libs/engine/core/analyzer-interface.ts index 11201ec..b9206fc 100644 --- a/libs/engine/core/analyzer-interface.ts +++ b/libs/engine/core/analyzer-interface.ts @@ -101,6 +101,7 @@ export enum Language { SOLIDITY = 'solidity', VYPER = 'vyper', RUST = 'rust', + SOROBAN = 'soroban', CAIRO = 'cairo', MOVE = 'move', JAVASCRIPT = 'javascript', diff --git a/libs/engine/src/scanner.rs b/libs/engine/src/scanner.rs index a9872db..582eb99 100644 --- a/libs/engine/src/scanner.rs +++ b/libs/engine/src/scanner.rs @@ -1,5 +1,5 @@ use anyhow::{Context, Result}; -use gasguard_rules::{RuleEngine, UnusedStateVariablesRule, VyperRuleEngine}; +use gasguard_rules::{RuleEngine, UnusedStateVariablesRule, VyperRuleEngine, SorobanRuleEngine}; use std::path::Path; /// Supported languages for scanning @@ -7,6 +7,7 @@ use std::path::Path; pub enum Language { Rust, Vyper, + Soroban, // Added Soroban support } impl Language { @@ -18,21 +19,47 @@ impl Language { _ => None, } } + + /// Detect language from file content heuristics + pub fn from_content(content: &str) -> Option { + // Check for Soroban-specific patterns + if content.contains("soroban_sdk") && + (content.contains("#[contract]") || + content.contains("#[contractimpl]") || + content.contains("#[contracttype]")) { + return Some(Language::Soroban); + } + + // Check for Vyper patterns + if content.contains("# @version") || content.contains("interface ") { + return Some(Language::Vyper); + } + + // Default to Rust for .rs files or general Rust code + if content.contains("fn main(") || content.contains("#[derive(") { + return Some(Language::Rust); + } + + None + } } pub struct ContractScanner { rule_engine: RuleEngine, vyper_rule_engine: VyperRuleEngine, + soroban_rule_engine: SorobanRuleEngine, // Added Soroban rule engine } impl ContractScanner { pub fn new() -> Self { let rule_engine = RuleEngine::new().add_rule(Box::new(UnusedStateVariablesRule)); let vyper_rule_engine = VyperRuleEngine::with_default_rules(); + let soroban_rule_engine = SorobanRuleEngine::with_default_rules(); // Initialize Soroban engine Self { rule_engine, vyper_rule_engine, + soroban_rule_engine, } } @@ -58,7 +85,9 @@ impl ContractScanner { source: String, language: Option, ) -> Result { - let violations = match language { + let detected_language = language.or_else(|| Language::from_content(content)); + + let violations = match detected_language { Some(Language::Rust) => self .rule_engine .analyze(content) @@ -67,9 +96,22 @@ impl ContractScanner { .vyper_rule_engine .analyze(content) .map_err(|e| anyhow::anyhow!(e))?, + Some(Language::Soroban) => self + .soroban_rule_engine + .analyze(content, &source) + .map_err(|e| anyhow::anyhow!(format!("Soroban analysis failed: {:?}", e)))?, None => { - // Unknown language, return empty violations - Vec::new() + // Unknown language, try to detect and analyze + if content.contains("soroban_sdk") { + self.soroban_rule_engine + .analyze(content, &source) + .map_err(|e| anyhow::anyhow!(format!("Soroban analysis failed: {:?}", e)))? + } else { + // Default to general Rust analysis + self.rule_engine + .analyze(content) + .map_err(|e| anyhow::anyhow!(e))? + } } }; @@ -101,6 +143,28 @@ impl ContractScanner { scan_time: chrono::Utc::now(), }) } + + /// Scan a Soroban contract file specifically + pub fn scan_soroban_file(&self, file_path: &Path) -> Result { + let content = std::fs::read_to_string(file_path) + .with_context(|| format!("Failed to read file: {:?}", file_path))?; + + self.scan_soroban_content(&content, file_path.to_string_lossy().to_string()) + } + + /// Scan Soroban contract content directly + pub fn scan_soroban_content(&self, content: &str, source: String) -> Result { + let violations = self + .soroban_rule_engine + .analyze(content, &source) + .map_err(|e| anyhow::anyhow!(format!("Soroban analysis failed: {:?}", e)))?; + + Ok(ScanResult { + source, + violations, + scan_time: chrono::Utc::now(), + }) + } pub fn scan_directory(&self, dir_path: &Path) -> Result> { let mut results = Vec::new(); @@ -111,11 +175,32 @@ impl ContractScanner { .filter(|e| { e.path().extension().map_or(false, |ext| { let ext_str = ext.to_str().unwrap_or(""); - Language::from_extension(ext_str).is_some() + ext_str == "rs" || ext_str == "vy" // Both Rust and Vyper files }) }) { - let result = self.scan_file(entry.path())?; + let content = std::fs::read_to_string(entry.path()) + .with_context(|| format!("Failed to read file: {:?}", entry.path()))?; + + // Detect language from content for better accuracy + let language = Language::from_content(&content).or_else(|| { + entry.path().extension() + .and_then(|ext| Language::from_extension(ext.to_str().unwrap_or(""))) + }); + + let result = match language { + Some(Language::Soroban) => { + self.scan_soroban_content(&content, entry.path().to_string_lossy().to_string())? + }, + Some(Language::Vyper) => { + self.scan_vyper_content(&content, entry.path().to_string_lossy().to_string())? + }, + _ => { + // Default to general scanning + self.scan_content_with_language(&content, entry.path().to_string_lossy().to_string(), language)? + } + }; + if !result.violations.is_empty() { results.push(result); } diff --git a/packages/rules/src/lib.rs b/packages/rules/src/lib.rs index cca9e16..e844646 100644 --- a/packages/rules/src/lib.rs +++ b/packages/rules/src/lib.rs @@ -1,7 +1,9 @@ pub mod rule_engine; pub mod unused_state_variables; pub mod vyper; +pub mod soroban; pub use rule_engine::*; pub use unused_state_variables::*; pub use vyper::*; +pub use soroban::*; diff --git a/packages/rules/src/rule_engine.rs b/packages/rules/src/rule_engine.rs index 917a08a..54a8b8e 100644 --- a/packages/rules/src/rule_engine.rs +++ b/packages/rules/src/rule_engine.rs @@ -2,6 +2,9 @@ use serde::{Deserialize, Serialize}; use std::collections::HashSet; use syn::{Expr, Item, ItemImpl, ItemStruct, Member, Pat}; +// Re-export from soroban module +pub use crate::soroban::{SorobanRuleEngine, SorobanContract, SorobanParser, SorobanResult}; + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RuleViolation { pub rule_name: String, diff --git a/packages/rules/src/soroban/analyzer.rs b/packages/rules/src/soroban/analyzer.rs new file mode 100644 index 0000000..1887d00 --- /dev/null +++ b/packages/rules/src/soroban/analyzer.rs @@ -0,0 +1,468 @@ +//! Soroban contract analysis module +//! +//! This module provides analysis capabilities for Soroban smart contracts, +//! detecting gas optimization opportunities, security issues, and best practices. + +use super::*; +use crate::{RuleViolation, ViolationSeverity}; + +/// Analyzes Soroban contracts for various issues +pub struct SorobanAnalyzer; + +impl SorobanAnalyzer { + /// Analyze a parsed Soroban contract + pub fn analyze_contract(contract: &SorobanContract) -> Vec { + let mut violations = Vec::new(); + + // Analyze contract types (structs) + for contract_type in &contract.contract_types { + violations.extend(Self::analyze_contract_type(contract_type, &contract.source)); + } + + // Analyze implementations + for implementation in &contract.implementations { + violations.extend(Self::analyze_implementation(implementation, &contract.source)); + } + + // Analyze overall contract structure + violations.extend(Self::analyze_contract_structure(contract)); + + violations + } + + /// Analyze a contract type (struct) for issues + fn analyze_contract_type(contract_type: &SorobanStruct, source: &str) -> Vec { + let mut violations = Vec::new(); + + // Check for unused state variables + violations.extend(Self::check_unused_state_variables(contract_type, source)); + + // Check for inefficient field types + violations.extend(Self::check_inefficient_field_types(contract_type)); + + // Check for missing pub fields in contract types + violations.extend(Self::check_missing_pub_fields(contract_type)); + + violations + } + + /// Analyze an implementation block for issues + fn analyze_implementation(implementation: &SorobanImpl, source: &str) -> Vec { + let mut violations = Vec::new(); + + for function in &implementation.functions { + violations.extend(Self::analyze_function(function, source)); + } + + // Check for unbounded loops + violations.extend(Self::check_unbounded_loops(implementation, source)); + + // Check for inefficient storage patterns + violations.extend(Self::check_storage_patterns(implementation, source)); + + violations + } + + /// Analyze a function for issues + fn analyze_function(function: &SorobanFunction, source: &str) -> Vec { + let mut violations = Vec::new(); + + // Check for expensive operations + violations.extend(Self::check_expensive_operations(function, source)); + + // Check parameter validation + violations.extend(Self::check_parameter_validation(function)); + + // Check return value handling + violations.extend(Self::check_return_values(function)); + + violations + } + + /// Analyze overall contract structure + fn analyze_contract_structure(contract: &SorobanContract) -> Vec { + let mut violations = Vec::new(); + + // Check for missing constructor + if !contract.implementations.iter().any(|imp| { + imp.functions.iter().any(|f| f.is_constructor) + }) { + violations.push(RuleViolation { + rule_name: "missing-constructor".to_string(), + description: "Contract should have a constructor function for initialization".to_string(), + suggestion: "Add a 'new' function that initializes the contract state".to_string(), + line_number: 1, + variable_name: contract.name.clone(), + severity: ViolationSeverity::Warning, + }); + } + + // Check for admin pattern + let has_admin = contract.contract_types.iter().any(|ct| { + ct.fields.iter().any(|f| + f.name.contains("admin") || + f.name.contains("owner") || + f.type_name.contains("Address") + ) + }); + + if !has_admin { + violations.push(RuleViolation { + rule_name: "missing-admin-pattern".to_string(), + description: "Consider adding an admin/owner field for access control".to_string(), + suggestion: "Add an 'admin: Address' field to your contract state".to_string(), + line_number: 1, + variable_name: contract.name.clone(), + severity: ViolationSeverity::Info, + }); + } + + violations + } + + /// Check for unused state variables + fn check_unused_state_variables(contract_type: &SorobanStruct, source: &str) -> Vec { + let mut violations = Vec::new(); + + for field in &contract_type.fields { + // Count occurrences of field name in the source (excluding struct definition) + let field_usage_count = source.matches(&field.name).count(); + + // If field is only mentioned in its own declaration, it's likely unused + // (this is a heuristic - a more sophisticated analysis would be needed for production) + if field_usage_count <= 1 { + violations.push(RuleViolation { + rule_name: "unused-state-variable".to_string(), + description: format!("State variable '{}' appears to be unused", field.name), + suggestion: format!("Remove unused state variable '{}' to save ledger storage", field.name), + line_number: field.line_number, + variable_name: field.name.clone(), + severity: ViolationSeverity::Warning, + }); + } + } + + violations + } + + /// Check for inefficient field types + fn check_inefficient_field_types(contract_type: &SorobanStruct) -> Vec { + let mut violations = Vec::new(); + + for field in &contract_type.fields { + // Check for overly large integer types + if field.type_name == "u128" || field.type_name == "i128" { + violations.push(RuleViolation { + rule_name: "inefficient-integer-type".to_string(), + description: format!("Field '{}' uses {} which may be unnecessarily large", field.name, field.type_name), + suggestion: format!("Consider using a smaller integer type like u64 or u32 if the range permits", field.name), + line_number: field.line_number, + variable_name: field.name.clone(), + severity: ViolationSeverity::Info, + }); + } + + // Check for String usage (prefer Symbol for known values) + if field.type_name == "String" { + violations.push(RuleViolation { + rule_name: "string-instead-of-symbol".to_string(), + description: format!("Field '{}' uses String type", field.name), + suggestion: "Consider using Symbol for fixed string values to save storage costs".to_string(), + line_number: field.line_number, + variable_name: field.name.clone(), + severity: ViolationSeverity::Info, + }); + } + } + + violations + } + + /// Check for missing pub fields in contract types + fn check_missing_pub_fields(contract_type: &SorobanStruct) -> Vec { + let mut violations = Vec::new(); + + for field in &contract_type.fields { + if matches!(field.visibility, FieldVisibility::Private) { + violations.push(RuleViolation { + rule_name: "private-contract-field".to_string(), + description: format!("Field '{}' is private but contract fields should typically be public", field.name), + suggestion: format!("Change '{}' to 'pub {}' to make it accessible", field.name, field.name), + line_number: field.line_number, + variable_name: field.name.clone(), + severity: ViolationSeverity::Warning, + }); + } + } + + violations + } + + /// Check for expensive operations in functions + fn check_expensive_operations(function: &SorobanFunction, source: &str) -> Vec { + let mut violations = Vec::new(); + let function_source = &function.raw_definition; + + // Check for string operations + if function_source.contains(".to_string()") || function_source.contains("String::from(") { + violations.push(RuleViolation { + rule_name: "expensive-string-operation".to_string(), + description: "String operations can be expensive in terms of gas/storage", + suggestion: "Consider using Symbol or Bytes for fixed data, or minimize string operations", + line_number: function.line_number, + variable_name: function.name.clone(), + severity: ViolationSeverity::Medium, + }); + } + + // Check for vector allocations without capacity + if function_source.contains("Vec::new()") && !function_source.contains("with_capacity") { + violations.push(RuleViolation { + rule_name: "vec-without-capacity".to_string(), + description: "Vec::new() without capacity can cause multiple reallocations", + suggestion: "Use Vec::with_capacity() to pre-allocate memory when size is known", + line_number: function.line_number, + variable_name: function.name.clone(), + severity: ViolationSeverity::Medium, + }); + } + + // Check for clone operations + if function_source.contains(".clone()") { + violations.push(RuleViolation { + rule_name: "unnecessary-clone".to_string(), + description: "Clone operations increase resource usage and gas costs", + suggestion: "Avoid unnecessary cloning, use references where possible", + line_number: function.line_number, + variable_name: function.name.clone(), + severity: ViolationSeverity::Medium, + }); + } + + violations + } + + /// Check parameter validation + fn check_parameter_validation(function: &SorobanFunction) -> Vec { + let mut violations = Vec::new(); + + // Check for missing validation on Address parameters + for param in &function.params { + if param.type_name.contains("Address") { + // Heuristic: if function name suggests it's a setter but doesn't validate address + if function.name.contains("set") || function.name.contains("transfer") { + violations.push(RuleViolation { + rule_name: "missing-address-validation".to_string(), + description: format!("Function '{}' takes Address parameter but may lack validation", function.name), + suggestion: "Validate Address parameters to prevent invalid addresses", + line_number: function.line_number, + variable_name: function.name.clone(), + severity: ViolationSeverity::Medium, + }); + } + } + } + + violations + } + + /// Check return value handling + fn check_return_values(function: &SorobanFunction) -> Vec { + let mut violations = Vec::new(); + + // Check for functions that should return Result but don't + if function.name.contains("transfer") || + function.name.contains("mint") || + function.name.contains("burn") { + if function.return_type.is_none() || + !function.return_type.as_ref().unwrap().contains("Result") { + violations.push(RuleViolation { + rule_name: "missing-error-handling".to_string(), + description: format!("Function '{}' should return Result for error handling", function.name), + suggestion: "Return Result<(), Error> to properly handle operation failures", + line_number: function.line_number, + variable_name: function.name.clone(), + severity: ViolationSeverity::Medium, + }); + } + } + + violations + } + + /// Check for unbounded loops + fn check_unbounded_loops(implementation: &SorobanImpl, source: &str) -> Vec { + let mut violations = Vec::new(); + + for function in &implementation.functions { + let func_source = &function.raw_definition; + + // Look for loops without clear bounds + if (func_source.contains("for ") || func_source.contains("while ")) && + !func_source.contains(".len()") && + !func_source.contains("range(") { + violations.push(RuleViolation { + rule_name: "unbounded-loop".to_string(), + description: format!("Function '{}' contains potentially unbounded loop", function.name), + suggestion: "Ensure loops have clear termination conditions to prevent CPU limit exhaustion", + line_number: function.line_number, + variable_name: function.name.clone(), + severity: ViolationSeverity::High, + }); + } + } + + violations + } + + /// Check for inefficient storage patterns + fn check_storage_patterns(implementation: &SorobanImpl, source: &str) -> Vec { + let mut violations = Vec::new(); + + // Check for multiple storage reads of the same key + let storage_reads: Vec<_> = implementation.functions + .iter() + .flat_map(|f| { + let func_source = &f.raw_definition; + // Simple heuristic: count occurrences of storage access patterns + let read_count = func_source.matches(".get(").count() + + func_source.matches(".load(").count(); + if read_count > 2 { + Some((f, read_count)) + } else { + None + } + }) + .collect(); + + for (function, read_count) in storage_reads { + violations.push(RuleViolation { + rule_name: "inefficient-storage-access".to_string(), + description: format!("Function '{}' performs {} storage reads - consider caching", function.name, read_count), + suggestion: "Cache frequently accessed storage values in local variables", + line_number: function.line_number, + variable_name: function.name.clone(), + severity: ViolationSeverity::Medium, + }); + } + + violations + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::soroban::parser::SorobanParser; + + #[test] + fn test_analyze_contract_with_issues() { + let source = r#" +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env}; + +#[contracttype] +pub struct BadContract { + admin: Address, + counter: u128, + unused_field: String, +} + +#[contractimpl] +impl BadContract { + pub fn new(admin: Address) -> Self { + Self { + admin, + counter: 0, + unused_field: "never_used".to_string(), + } + } + + pub fn increment(&mut self) { + self.counter += 1; + let vec = Vec::new(); + vec.push(1); + } +} +"#; + + let contract = SorobanParser::parse_contract(source, "test.rs").unwrap(); + let violations = SorobanAnalyzer::analyze_contract(&contract); + + // Should detect several issues + assert!(!violations.is_empty()); + + // Check for specific violations + let unused_var_found = violations.iter().any(|v| + v.rule_name == "unused-state-variable" && v.variable_name == "unused_field" + ); + assert!(unused_var_found); + + let inefficient_type_found = violations.iter().any(|v| + v.rule_name == "inefficient-integer-type" && v.variable_name == "counter" + ); + assert!(inefficient_type_found); + + let private_field_found = violations.iter().any(|v| + v.rule_name == "private-contract-field" && v.variable_name == "admin" + ); + assert!(private_field_found); + } + + #[test] + fn test_analyze_well_optimized_contract() { + let source = r#" +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, Symbol}; + +#[contracttype] +pub struct GoodContract { + pub admin: Address, + pub total_supply: u64, + pub balances: Map, +} + +#[contractimpl] +impl GoodContract { + pub fn new(admin: Address, initial_supply: u64) -> Self { + let mut balances = Map::new(); + balances.set(admin, initial_supply); + + Self { + admin, + total_supply: initial_supply, + balances, + } + } + + pub fn transfer(from: Address, to: Address, amount: u64) -> Result<(), Error> { + // Proper validation and error handling + if amount == 0 { + return Err(Error::InvalidAmount); + } + + let from_balance = self.balances.get(from).unwrap_or(0); + if from_balance < amount { + return Err(Error::InsufficientBalance); + } + + let to_balance = self.balances.get(to).unwrap_or(0); + + self.balances.set(from, from_balance - amount); + self.balances.set(to, to_balance + amount); + + Ok(()) + } +} +"#; + + let contract = SorobanParser::parse_contract(source, "test.rs").unwrap(); + let violations = SorobanAnalyzer::analyze_contract(&contract); + + // Well-optimized contract should have minimal violations + // Most should be informational rather than critical + let critical_violations: Vec<_> = violations.iter() + .filter(|v| matches!(v.severity, ViolationSeverity::High | ViolationSeverity::Error)) + .collect(); + + assert!(critical_violations.is_empty() || critical_violations.len() <= 1); + } +} \ No newline at end of file diff --git a/packages/rules/src/soroban/mod.rs b/packages/rules/src/soroban/mod.rs new file mode 100644 index 0000000..226ab91 --- /dev/null +++ b/packages/rules/src/soroban/mod.rs @@ -0,0 +1,128 @@ +//! Soroban contract parsing and analysis module +//! +//! This module provides AST structures and parsing utilities for Soroban smart contracts +//! built on the Stellar network. It handles parsing of Soroban-specific macros like +//! `#[contract]`, `#[contractimpl]`, and `#[contracttype]`. + +pub mod parser; +pub mod analyzer; +pub mod rule_engine; + +pub use parser::*; +pub use analyzer::*; +pub use rule_engine::*; + +/// Represents a Soroban contract structure +#[derive(Debug, Clone, PartialEq)] +pub struct SorobanContract { + /// The name of the contract + pub name: String, + /// Struct definitions marked with #[contracttype] + pub contract_types: Vec, + /// Implementation blocks marked with #[contractimpl] + pub implementations: Vec, + /// Raw contract source code + pub source: String, + /// File path of the contract + pub file_path: String, +} + +/// Represents a struct definition with #[contracttype] macro +#[derive(Debug, Clone, PartialEq)] +pub struct SorobanStruct { + /// Name of the struct + pub name: String, + /// Fields in the struct + pub fields: Vec, + /// Line number where the struct is defined + pub line_number: usize, + /// Raw struct definition + pub raw_definition: String, +} + +/// Represents a field in a Soroban struct +#[derive(Debug, Clone, PartialEq)] +pub struct SorobanField { + /// Name of the field + pub name: String, + /// Type of the field + pub type_name: String, + /// Visibility modifier + pub visibility: FieldVisibility, + /// Line number of the field + pub line_number: usize, +} + +/// Visibility modifiers for struct fields +#[derive(Debug, Clone, PartialEq)] +pub enum FieldVisibility { + Public, + Private, +} + +/// Represents an implementation block with #[contractimpl] macro +#[derive(Debug, Clone, PartialEq)] +pub struct SorobanImpl { + /// Name of the impl block (usually the struct name) + pub target: String, + /// Functions defined in the impl block + pub functions: Vec, + /// Line number where the impl starts + pub line_number: usize, + /// Raw impl definition + pub raw_definition: String, +} + +/// Represents a function in a Soroban contract +#[derive(Debug, Clone, PartialEq)] +pub struct SorobanFunction { + /// Name of the function + pub name: String, + /// Function parameters + pub params: Vec, + /// Return type + pub return_type: Option, + /// Visibility (public, private) + pub visibility: FunctionVisibility, + /// Whether it's a constructor function + pub is_constructor: bool, + /// Line number where the function is defined + pub line_number: usize, + /// Raw function definition + pub raw_definition: String, +} + +/// Represents a function parameter +#[derive(Debug, Clone, PartialEq)] +pub struct SorobanParam { + /// Parameter name + pub name: String, + /// Parameter type + pub type_name: String, +} + +/// Function visibility modifiers +#[derive(Debug, Clone, PartialEq)] +pub enum FunctionVisibility { + Public, + Private, +} + +/// Error types for Soroban parsing +#[derive(Debug, thiserror::Error)] +pub enum SorobanParseError { + #[error("Failed to parse Soroban contract: {0}")] + ParseError(String), + + #[error("Missing required Soroban macro: {0}")] + MissingMacro(String), + + #[error("Invalid contract structure: {0}")] + InvalidStructure(String), + + #[error("IO error: {0}")] + IoError(#[from] std::io::Error), +} + +/// Result type for Soroban parsing operations +pub type SorobanResult = Result; \ No newline at end of file diff --git a/packages/rules/src/soroban/parser.rs b/packages/rules/src/soroban/parser.rs new file mode 100644 index 0000000..eb9582c --- /dev/null +++ b/packages/rules/src/soroban/parser.rs @@ -0,0 +1,520 @@ +//! Soroban contract parser implementation +//! +//! This module provides parsing capabilities for Soroban smart contracts, +//! extracting AST-like structures from Rust code containing Soroban macros. + +use super::*; +use regex::Regex; +use std::collections::HashMap; + +/// Parses Soroban contracts from source code +pub struct SorobanParser; + +impl SorobanParser { + /// Parse a Soroban contract from source code + pub fn parse_contract(source: &str, file_path: &str) -> SorobanResult { + let lines: Vec<&str> = source.lines().collect(); + + // Extract contract name from #[contract] attribute + let contract_name = Self::extract_contract_name(source)?; + + // Parse struct definitions with #[contracttype] + let contract_types = Self::parse_contract_types(&lines)?; + + // Parse implementation blocks with #[contractimpl] + let implementations = Self::parse_implementations(&lines)?; + + Ok(SorobanContract { + name: contract_name, + contract_types, + implementations, + source: source.to_string(), + file_path: file_path.to_string(), + }) + } + + /// Extract contract name from #[contract] attribute + fn extract_contract_name(source: &str) -> SorobanResult { + let contract_re = Regex::new(r#"#\s*\[\s*contract\s*\(\s*(.*?)\s*\)\s*\]"#).unwrap(); + + if let Some(captures) = contract_re.captures(source) { + if let Some(name) = captures.get(1) { + return Ok(name.as_str().trim().to_string()); + } + } + + // If no explicit name, try to infer from struct names + let struct_re = Regex::new(r#"#\s*\[\s*contracttype\s*\][\s\S]*?struct\s+(\w+)"#).unwrap(); + if let Some(captures) = struct_re.captures(source) { + if let Some(name) = captures.get(1) { + return Ok(name.as_str().to_string()); + } + } + + Err(SorobanParseError::MissingMacro( + "Could not determine contract name from #[contract] or #[contracttype] attributes".to_string() + )) + } + + /// Parse struct definitions with #[contracttype] macro + fn parse_contract_types(lines: &[&str]) -> SorobanResult> { + let mut structs = Vec::new(); + let mut i = 0; + + while i < lines.len() { + // Look for #[contracttype] attribute + if lines[i].trim().starts_with("#[contracttype]") { + let line_number = i + 1; + + // Skip to the struct definition + i += 1; + while i < lines.len() && !lines[i].trim().starts_with("struct") { + i += 1; + } + + if i >= lines.len() { + break; + } + + // Parse the struct + if let Some(soroban_struct) = Self::parse_single_struct(&lines[i..], line_number)? { + structs.push(soroban_struct); + } + } + i += 1; + } + + Ok(structs) + } + + /// Parse a single struct definition + fn parse_single_struct(lines: &[&str], start_line: usize) -> SorobanResult> { + if lines.is_empty() || !lines[0].trim().starts_with("struct") { + return Ok(None); + } + + let struct_line = lines[0].trim(); + + // Extract struct name + let name_re = Regex::new(r"struct\s+(\w+)").unwrap(); + let name = name_re.captures(struct_line) + .and_then(|caps| caps.get(1)) + .map(|m| m.as_str().to_string()) + .ok_or_else(|| SorobanParseError::ParseError( + format!("Could not parse struct name from: {}", struct_line) + ))?; + + // Find the opening brace + let mut brace_count = 0; + let mut struct_lines = vec![struct_line]; + let mut i = 1; + + while i < lines.len() { + let line = lines[i].trim(); + struct_lines.push(line); + + if line.contains('{') { + brace_count += 1; + } + if line.contains('}') { + brace_count -= 1; + if brace_count == 0 { + break; + } + } + i += 1; + } + + // Parse fields + let fields = Self::parse_struct_fields(&struct_lines, start_line)?; + + Ok(Some(SorobanStruct { + name, + fields, + line_number: start_line, + raw_definition: struct_lines.join("\n"), + })) + } + + /// Parse fields from a struct definition + fn parse_struct_fields(lines: &[&str], base_line: usize) -> SorobanResult> { + let mut fields = Vec::new(); + + // Join lines and extract content between braces + let full_content = lines.join(" "); + let fields_content = Self::extract_between_braces(&full_content) + .ok_or_else(|| SorobanParseError::ParseError("Could not extract struct fields".to_string()))?; + + // Split by comma to get individual fields + let field_parts: Vec<&str> = fields_content.split(',').collect(); + + for (index, field_part) in field_parts.iter().enumerate() { + let field_part = field_part.trim(); + if field_part.is_empty() { + continue; + } + + // Parse field: visibility, name, and type + if let Some(field) = Self::parse_field(field_part, base_line + index)? { + fields.push(field); + } + } + + Ok(fields) + } + + /// Parse a single field definition + fn parse_field(field_str: &str, line_number: usize) -> SorobanResult> { + let field_str = field_str.trim(); + if field_str.is_empty() { + return Ok(None); + } + + // Handle visibility modifiers + let (visibility, remaining) = if field_str.starts_with("pub ") { + (FieldVisibility::Public, &field_str[4..]) + } else { + (FieldVisibility::Private, field_str) + }; + + // Split by colon to separate name and type + let parts: Vec<&str> = remaining.split(':').collect(); + if parts.len() != 2 { + return Err(SorobanParseError::ParseError( + format!("Invalid field format: {}", field_str) + )); + } + + let name = parts[0].trim().to_string(); + let type_name = parts[1].trim().to_string(); + + Ok(Some(SorobanField { + name, + type_name, + visibility, + line_number, + })) + } + + /// Parse implementation blocks with #[contractimpl] macro + fn parse_implementations(lines: &[&str]) -> SorobanResult> { + let mut implementations = Vec::new(); + let mut i = 0; + + while i < lines.len() { + // Look for #[contractimpl] attribute + if lines[i].trim().starts_with("#[contractimpl]") { + let line_number = i + 1; + + // Skip to the impl definition + i += 1; + while i < lines.len() && !lines[i].trim().starts_with("impl") { + i += 1; + } + + if i >= lines.len() { + break; + } + + // Parse the impl block + if let Some(implementation) = Self::parse_single_impl(&lines[i..], line_number)? { + implementations.push(implementation); + } + } + i += 1; + } + + Ok(implementations) + } + + /// Parse a single implementation block + fn parse_single_impl(lines: &[&str], start_line: usize) -> SorobanResult> { + if lines.is_empty() || !lines[0].trim().starts_with("impl") { + return Ok(None); + } + + let impl_line = lines[0].trim(); + + // Extract target type (struct name) + let target_re = Regex::new(r"impl\s+(?:.*?\s+for\s+)?(\w+)").unwrap(); + let target = target_re.captures(impl_line) + .and_then(|caps| caps.get(1)) + .map(|m| m.as_str().to_string()) + .ok_or_else(|| SorobanParseError::ParseError( + format!("Could not parse impl target from: {}", impl_line) + ))?; + + // Find the opening brace and parse functions + let mut brace_count = 0; + let mut impl_lines = vec![impl_line]; + let mut i = 1; + let mut functions = Vec::new(); + + while i < lines.len() { + let line = lines[i].trim(); + impl_lines.push(line); + + if line.contains('{') { + brace_count += 1; + } + + if line.contains('}') { + brace_count -= 1; + if brace_count == 0 { + break; + } + } + + // Parse function definitions within the impl block + if brace_count == 1 && line.starts_with("pub ") && line.contains("fn ") { + if let Some(function) = Self::parse_function(&lines[i..], start_line + i)? { + functions.push(function); + } + } + + i += 1; + } + + Ok(Some(SorobanImpl { + target, + functions, + line_number: start_line, + raw_definition: impl_lines.join("\n"), + })) + } + + /// Parse a function definition + fn parse_function(lines: &[&str], start_line: usize) -> SorobanResult> { + if lines.is_empty() { + return Ok(None); + } + + let func_line = lines[0].trim(); + if !func_line.starts_with("pub ") || !func_line.contains("fn ") { + return Ok(None); + } + + // Extract function name + let name_re = Regex::new(r"fn\s+(\w+)").unwrap(); + let name = name_re.captures(func_line) + .and_then(|caps| caps.get(1)) + .map(|m| m.as_str().to_string()) + .ok_or_else(|| SorobanParseError::ParseError( + format!("Could not parse function name from: {}", func_line) + ))?; + + // Extract parameters + let params = Self::extract_parameters(func_line)?; + + // Extract return type + let return_type = Self::extract_return_type(func_line)?; + + // Determine if it's a constructor (new function typically) + let is_constructor = name == "new" || name.ends_with("_init"); + + // Get full function definition (find closing brace) + let mut brace_count = 0; + let mut func_lines = vec![func_line]; + let mut i = 1; + + if func_line.contains('{') { + brace_count += 1; + } + + while i < lines.len() && brace_count > 0 { + let line = lines[i].trim(); + func_lines.push(line); + + if line.contains('{') { + brace_count += 1; + } + if line.contains('}') { + brace_count -= 1; + } + i += 1; + } + + Ok(Some(SorobanFunction { + name, + params, + return_type, + visibility: FunctionVisibility::Public, // All contract functions are public + is_constructor, + line_number: start_line, + raw_definition: func_lines.join("\n"), + })) + } + + /// Extract function parameters + fn extract_parameters(func_signature: &str) -> SorobanResult> { + let params_section = Self::extract_between_parentheses(func_signature) + .ok_or_else(|| SorobanParseError::ParseError("Could not extract parameters".to_string()))?; + + let mut params = Vec::new(); + + // Split by comma, handling nested parentheses + let param_parts = Self::split_preserving_parentheses(params_section, ','); + + for param_part in param_parts { + let param_part = param_part.trim(); + if param_part.is_empty() { + continue; + } + + // Split by colon to separate name and type + let parts: Vec<&str> = param_part.split(':').collect(); + if parts.len() == 2 { + let name = parts[0].trim().to_string(); + let type_name = parts[1].trim().to_string(); + params.push(SorobanParam { name, type_name }); + } + } + + Ok(params) + } + + /// Extract return type from function signature + fn extract_return_type(func_signature: &str) -> SorobanResult> { + // Look for -> return type pattern + let return_re = Regex::new(r"->\s*([^{\n]+)").unwrap(); + + if let Some(captures) = return_re.captures(func_signature) { + if let Some(return_type) = captures.get(1) { + let clean_type = return_type.as_str().trim().to_string(); + if !clean_type.is_empty() { + return Ok(Some(clean_type)); + } + } + } + + Ok(None) + } + + /// Helper function to extract content between parentheses + fn extract_between_parentheses(text: &str) -> Option { + let start = text.find('(')?; + let mut paren_count = 1; + let mut end = start + 1; + + while end < text.len() && paren_count > 0 { + match text.chars().nth(end).unwrap() { + '(' => paren_count += 1, + ')' => paren_count -= 1, + _ => {} + } + if paren_count > 0 { + end += 1; + } + } + + if paren_count == 0 { + Some(text[start + 1..end].to_string()) + } else { + None + } + } + + /// Helper function to extract content between braces + fn extract_between_braces(text: &str) -> Option { + let start = text.find('{')?; + let mut brace_count = 1; + let mut end = start + 1; + + while end < text.len() && brace_count > 0 { + match text.chars().nth(end).unwrap() { + '{' => brace_count += 1, + '}' => brace_count -= 1, + _ => {} + } + if brace_count > 0 { + end += 1; + } + } + + if brace_count == 0 { + Some(text[start + 1..end].to_string()) + } else { + None + } + } + + /// Split string by delimiter while preserving parentheses nesting + fn split_preserving_parentheses(text: &str, delimiter: char) -> Vec { + let mut result = Vec::new(); + let mut current = String::new(); + let mut paren_count = 0; + let mut bracket_count = 0; + let mut brace_count = 0; + + for ch in text.chars() { + match ch { + '(' => paren_count += 1, + ')' => paren_count -= 1, + '[' => bracket_count += 1, + ']' => bracket_count -= 1, + '{' => brace_count += 1, + '}' => brace_count -= 1, + _ => {} + } + + if ch == delimiter && paren_count == 0 && bracket_count == 0 && brace_count == 0 { + result.push(current.trim().to_string()); + current = String::new(); + } else { + current.push(ch); + } + } + + if !current.trim().is_empty() { + result.push(current.trim().to_string()); + } + + result + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_simple_contract() { + let source = r#" +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env}; + +#[contracttype] +pub struct TokenContract { + pub admin: Address, + pub total_supply: u64, +} + +#[contractimpl] +impl TokenContract { + pub fn new(admin: Address, supply: u64) -> Self { + Self { + admin, + total_supply: supply, + } + } + + pub fn get_total_supply(&self) -> u64 { + self.total_supply + } +} +"#; + + let contract = SorobanParser::parse_contract(source, "test.rs").unwrap(); + + assert_eq!(contract.contract_types.len(), 1); + assert_eq!(contract.implementations.len(), 1); + + let struct_def = &contract.contract_types[0]; + assert_eq!(struct_def.name, "TokenContract"); + assert_eq!(struct_def.fields.len(), 2); + + let impl_block = &contract.implementations[0]; + assert_eq!(impl_block.functions.len(), 2); + assert_eq!(impl_block.functions[0].name, "new"); + assert_eq!(impl_block.functions[1].name, "get_total_supply"); + } +} \ No newline at end of file diff --git a/packages/rules/src/soroban/rule_engine.rs b/packages/rules/src/soroban/rule_engine.rs new file mode 100644 index 0000000..06c5e35 --- /dev/null +++ b/packages/rules/src/soroban/rule_engine.rs @@ -0,0 +1,654 @@ +//! Soroban-specific rule engine +//! +//! This module provides a specialized rule engine for analyzing Soroban smart contracts +//! with rules tailored to Soroban's unique characteristics and gas optimization patterns. + +use super::soroban::{SorobanAnalyzer, SorobanContract, SorobanParser, SorobanResult}; +use crate::{RuleViolation, ViolationSeverity}; +use std::collections::HashMap; + +/// Soroban-specific rule engine +pub struct SorobanRuleEngine { + /// Active rules in the engine + rules: HashMap>, + /// Whether to enable all rules by default + enable_all_by_default: bool, +} + +impl SorobanRuleEngine { + /// Create a new Soroban rule engine with default rules + pub fn with_default_rules() -> Self { + let mut engine = Self::new(); + engine.add_default_rules(); + engine + } + + /// Create a new empty Soroban rule engine + pub fn new() -> Self { + Self { + rules: HashMap::new(), + enable_all_by_default: true, + } + } + + /// Add a rule to the engine + pub fn add_rule(&mut self, rule: R) -> &mut Self { + self.rules.insert(rule.id().to_string(), Box::new(rule)); + self + } + + /// Add all default Soroban rules + fn add_default_rules(&mut self) { + self.add_rule(UnusedStateVariablesRule) + .add_rule(InefficientStorageAccessRule) + .add_rule(UnboundedLoopRule) + .add_rule(ExpensiveStringOperationsRule) + .add_rule(MissingConstructorRule) + .add_rule(AdminPatternRule) + .add_rule(InefficientIntegerTypesRule) + .add_rule(MissingErrorHandlingRule); + } + + /// Analyze Soroban contract source code + pub fn analyze(&self, source: &str, file_path: &str) -> SorobanResult> { + // Parse the contract + let contract = SorobanParser::parse_contract(source, file_path)?; + + // Run analysis + let violations = SorobanAnalyzer::analyze_contract(&contract); + + // Apply active rules + let mut all_violations = violations; + for rule in self.rules.values() { + if rule.is_enabled() { + all_violations.extend(rule.apply(&contract)); + } + } + + Ok(all_violations) + } + + /// Get all registered rules + pub fn get_rules(&self) -> Vec<&dyn SorobanRule> { + self.rules.values().map(|r| r.as_ref()).collect() + } + + /// Enable or disable a specific rule + pub fn set_rule_enabled(&mut self, rule_id: &str, enabled: bool) { + if let Some(rule) = self.rules.get_mut(rule_id) { + rule.set_enabled(enabled); + } + } +} + +/// Trait for Soroban-specific rules +pub trait SorobanRule: Send + Sync { + /// Unique identifier for the rule + fn id(&self) -> &str; + + /// Human-readable name of the rule + fn name(&self) -> &str; + + /// Description of what the rule checks for + fn description(&self) -> &str; + + /// Severity level of violations from this rule + fn severity(&self) -> ViolationSeverity; + + /// Whether this rule is currently enabled + fn is_enabled(&self) -> bool; + + /// Enable or disable the rule + fn set_enabled(&mut self, enabled: bool); + + /// Apply the rule to a parsed Soroban contract + fn apply(&self, contract: &SorobanContract) -> Vec; +} + +/// Rule for detecting unused state variables +pub struct UnusedStateVariablesRule { + enabled: bool, +} + +impl Default for UnusedStateVariablesRule { + fn default() -> Self { + Self { enabled: true } + } +} + +impl SorobanRule for UnusedStateVariablesRule { + fn id(&self) -> &str { + "soroban-unused-state-variables" + } + + fn name(&self) -> &str { + "Unused State Variables" + } + + fn description(&self) -> &str { + "Detects state variables that are declared but never used" + } + + fn severity(&self) -> ViolationSeverity { + ViolationSeverity::Warning + } + + fn is_enabled(&self) -> bool { + self.enabled + } + + fn set_enabled(&mut self, enabled: bool) { + self.enabled = enabled; + } + + fn apply(&self, contract: &SorobanContract) -> Vec { + let mut violations = Vec::new(); + + for contract_type in &contract.contract_types { + for field in &contract_type.fields { + // Simple heuristic: if field name appears only once in source, it's likely unused + let occurrences = contract.source.matches(&field.name).count(); + if occurrences <= 1 { + violations.push(RuleViolation { + rule_name: self.id().to_string(), + description: format!("State variable '{}' appears to be unused", field.name), + suggestion: format!("Remove unused state variable '{}' to save ledger storage costs", field.name), + line_number: field.line_number, + variable_name: field.name.clone(), + severity: self.severity(), + }); + } + } + } + + violations + } +} + +/// Rule for detecting inefficient storage access patterns +pub struct InefficientStorageAccessRule { + enabled: bool, +} + +impl Default for InefficientStorageAccessRule { + fn default() -> Self { + Self { enabled: true } + } +} + +impl SorobanRule for InefficientStorageAccessRule { + fn id(&self) -> &str { + "soroban-inefficient-storage" + } + + fn name(&self) -> &str { + "Inefficient Storage Access" + } + + fn description(&self) -> &str { + "Detects multiple reads/writes to the same storage key without caching" + } + + fn severity(&self) -> ViolationSeverity { + ViolationSeverity::Medium + } + + fn is_enabled(&self) -> bool { + self.enabled + } + + fn set_enabled(&mut self, enabled: bool) { + self.enabled = enabled; + } + + fn apply(&self, contract: &SorobanContract) -> Vec { + let mut violations = Vec::new(); + + for implementation in &contract.implementations { + for function in &implementation.functions { + let func_source = &function.raw_definition; + + // Count storage operations + let get_count = func_source.matches(".get(").count(); + let set_count = func_source.matches(".set(").count(); + let load_count = func_source.matches(".load(").count(); + let store_count = func_source.matches(".store(").count(); + + let total_ops = get_count + set_count + load_count + store_count; + + // If there are many storage operations, flag for review + if total_ops > 3 { + violations.push(RuleViolation { + rule_name: self.id().to_string(), + description: format!("Function '{}' performs {} storage operations - consider caching", function.name, total_ops), + suggestion: "Cache frequently accessed storage values in local variables to reduce ledger interactions", + line_number: function.line_number, + variable_name: function.name.clone(), + severity: self.severity(), + }); + } + } + } + + violations + } +} + +/// Rule for detecting unbounded loops +pub struct UnboundedLoopRule { + enabled: bool, +} + +impl Default for UnboundedLoopRule { + fn default() -> Self { + Self { enabled: true } + } +} + +impl SorobanRule for UnboundedLoopRule { + fn id(&self) -> &str { + "soroban-unbounded-loop" + } + + fn name(&self) -> &str { + "Unbounded Loop Detection" + } + + fn description(&self) -> &str { + "Detects loops without clear termination conditions that could exhaust CPU limits" + } + + fn severity(&self) -> ViolationSeverity { + ViolationSeverity::High + } + + fn is_enabled(&self) -> bool { + self.enabled + } + + fn set_enabled(&mut self, enabled: bool) { + self.enabled = enabled; + } + + fn apply(&self, contract: &SorobanContract) -> Vec { + let mut violations = Vec::new(); + + for implementation in &contract.implementations { + for function in &implementation.functions { + let func_source = &function.raw_definition; + + // Look for potentially unbounded loops + if (func_source.contains("loop {") || + func_source.contains("while ") || + func_source.contains("for ")) && + !(func_source.contains(".len()") || + func_source.contains("range(") || + func_source.contains("..")) { + + violations.push(RuleViolation { + rule_name: self.id().to_string(), + description: format!("Function '{}' contains potentially unbounded loop", function.name), + suggestion: "Ensure loops have clear termination conditions to prevent CPU limit exhaustion", + line_number: function.line_number, + variable_name: function.name.clone(), + severity: self.severity(), + }); + } + } + } + + violations + } +} + +/// Rule for detecting expensive string operations +pub struct ExpensiveStringOperationsRule { + enabled: bool, +} + +impl Default for ExpensiveStringOperationsRule { + fn default() -> Self { + Self { enabled: true } + } +} + +impl SorobanRule for ExpensiveStringOperationsRule { + fn id(&self) -> &str { + "soroban-expensive-strings" + } + + fn name(&self) -> &str { + "Expensive String Operations" + } + + fn description(&self) -> &str { + "Detects expensive string operations that increase gas/storage costs" + } + + fn severity(&self) -> ViolationSeverity { + ViolationSeverity::Medium + } + + fn is_enabled(&self) -> bool { + self.enabled + } + + fn set_enabled(&mut self, enabled: bool) { + self.enabled = enabled; + } + + fn apply(&self, contract: &SorobanContract) -> Vec { + let mut violations = Vec::new(); + + for implementation in &contract.implementations { + for function in &implementation.functions { + let func_source = &function.raw_definition; + + if func_source.contains(".to_string()") || + func_source.contains("String::from(") || + func_source.contains("format!(") { + + violations.push(RuleViolation { + rule_name: self.id().to_string(), + description: format!("Function '{}' uses expensive string operations", function.name), + suggestion: "Consider using Symbol or Bytes for fixed data, or minimize string operations to reduce gas costs", + line_number: function.line_number, + variable_name: function.name.clone(), + severity: self.severity(), + }); + } + } + } + + violations + } +} + +/// Rule for detecting missing constructors +pub struct MissingConstructorRule { + enabled: bool, +} + +impl Default for MissingConstructorRule { + fn default() -> Self { + Self { enabled: true } + } +} + +impl SorobanRule for MissingConstructorRule { + fn id(&self) -> &str { + "soroban-missing-constructor" + } + + fn name(&self) -> &str { + "Missing Constructor" + } + + fn description(&self) -> &str { + "Detects contracts without constructor functions for initialization" + } + + fn severity(&self) -> ViolationSeverity { + ViolationSeverity::Warning + } + + fn is_enabled(&self) -> bool { + self.enabled + } + + fn set_enabled(&mut self, enabled: bool) { + self.enabled = enabled; + } + + fn apply(&self, contract: &SorobanContract) -> Vec { + let has_constructor = contract.implementations.iter().any(|imp| { + imp.functions.iter().any(|f| f.is_constructor) + }); + + if !has_constructor { + vec![RuleViolation { + rule_name: self.id().to_string(), + description: "Contract lacks a constructor function for initialization".to_string(), + suggestion: "Add a 'new' function that initializes the contract state properly".to_string(), + line_number: 1, + variable_name: contract.name.clone(), + severity: self.severity(), + }] + } else { + Vec::new() + } + } +} + +/// Rule for suggesting admin pattern +pub struct AdminPatternRule { + enabled: bool, +} + +impl Default for AdminPatternRule { + fn default() -> Self { + Self { enabled: true } + } +} + +impl SorobanRule for AdminPatternRule { + fn id(&self) -> &str { + "soroban-admin-pattern" + } + + fn name(&self) -> &str { + "Admin Pattern Suggestion" + } + + fn description(&self) -> &str { + "Suggests adding admin/owner pattern for access control" + } + + fn severity(&self) -> ViolationSeverity { + ViolationSeverity::Info + } + + fn is_enabled(&self) -> bool { + self.enabled + } + + fn set_enabled(&mut self, enabled: bool) { + self.enabled = enabled; + } + + fn apply(&self, contract: &SorobanContract) -> Vec { + let has_admin = contract.contract_types.iter().any(|ct| { + ct.fields.iter().any(|f| + f.name.contains("admin") || + f.name.contains("owner") || + f.type_name.contains("Address") + ) + }); + + if !has_admin { + vec![RuleViolation { + rule_name: self.id().to_string(), + description: "Consider adding an admin/owner field for access control", + suggestion: "Add an 'admin: Address' field to your contract state for administrative functions", + line_number: 1, + variable_name: contract.name.clone(), + severity: self.severity(), + }] + } else { + Vec::new() + } + } +} + +/// Rule for detecting inefficient integer types +pub struct InefficientIntegerTypesRule { + enabled: bool, +} + +impl Default for InefficientIntegerTypesRule { + fn default() -> Self { + Self { enabled: true } + } +} + +impl SorobanRule for InefficientIntegerTypesRule { + fn id(&self) -> &str { + "soroban-inefficient-integers" + } + + fn name(&self) -> &str { + "Inefficient Integer Types" + } + + fn description(&self) -> &str { + "Detects use of unnecessarily large integer types" + } + + fn severity(&self) -> ViolationSeverity { + ViolationSeverity::Info + } + + fn is_enabled(&self) -> bool { + self.enabled + } + + fn set_enabled(&mut self, enabled: bool) { + self.enabled = enabled; + } + + fn apply(&self, contract: &SorobanContract) -> Vec { + let mut violations = Vec::new(); + + for contract_type in &contract.contract_types { + for field in &contract_type.fields { + if field.type_name == "u128" || field.type_name == "i128" { + violations.push(RuleViolation { + rule_name: self.id().to_string(), + description: format!("Field '{}' uses {} which may be unnecessarily large", field.name, field.type_name), + suggestion: format!("Consider using a smaller integer type like u64 or u32 if the range permits"), + line_number: field.line_number, + variable_name: field.name.clone(), + severity: self.severity(), + }); + } + } + } + + violations + } +} + +/// Rule for detecting missing error handling +pub struct MissingErrorHandlingRule { + enabled: bool, +} + +impl Default for MissingErrorHandlingRule { + fn default() -> Self { + Self { enabled: true } + } +} + +impl SorobanRule for MissingErrorHandlingRule { + fn id(&self) -> &str { + "soroban-missing-error-handling" + } + + fn name(&self) -> &str { + "Missing Error Handling" + } + + fn description(&self) -> &str { + "Detects functions that should return Result but don't" + } + + fn severity(&self) -> ViolationSeverity { + ViolationSeverity::Medium + } + + fn is_enabled(&self) -> bool { + self.enabled + } + + fn set_enabled(&mut self, enabled: bool) { + self.enabled = enabled; + } + + fn apply(&self, contract: &SorobanContract) -> Vec { + let mut violations = Vec::new(); + + for implementation in &contract.implementations { + for function in &implementation.functions { + // Functions that modify state should return Result + if (function.name.contains("transfer") || + function.name.contains("mint") || + function.name.contains("burn") || + function.name.contains("set")) && + (function.return_type.is_none() || + !function.return_type.as_ref().unwrap().contains("Result")) { + + violations.push(RuleViolation { + rule_name: self.id().to_string(), + description: format!("Function '{}' should return Result for proper error handling", function.name), + suggestion: "Return Result<(), Error> to properly handle operation failures and provide better error reporting", + line_number: function.line_number, + variable_name: function.name.clone(), + severity: self.severity(), + }); + } + } + } + + violations + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_soroban_rule_engine_creation() { + let engine = SorobanRuleEngine::with_default_rules(); + assert!(!engine.get_rules().is_empty()); + + let rule_ids: Vec<_> = engine.get_rules().iter().map(|r| r.id()).collect(); + assert!(rule_ids.contains(&"soroban-unused-state-variables")); + assert!(rule_ids.contains(&"soroban-inefficient-storage")); + } + + #[test] + fn test_unused_state_variables_rule() { + let source = r#" +use soroban_sdk::{contract, contractimpl, contracttype, Address}; + +#[contracttype] +pub struct TestContract { + pub admin: Address, + pub unused_counter: u64, +} + +#[contractimpl] +impl TestContract { + pub fn new(admin: Address) -> Self { + Self { admin, unused_counter: 0 } + } + + pub fn get_admin(&self) -> Address { + self.admin + } +} +"#; + + let engine = SorobanRuleEngine::new() + .add_rule(UnusedStateVariablesRule::default()); + + let violations = engine.analyze(source, "test.rs").unwrap(); + + let unused_found = violations.iter().any(|v| + v.rule_name == "soroban-unused-state-variables" && + v.variable_name == "unused_counter" + ); + assert!(unused_found); + } +} \ No newline at end of file diff --git a/packages/rules/src/soroban/tests.rs b/packages/rules/src/soroban/tests.rs new file mode 100644 index 0000000..05e0d1d --- /dev/null +++ b/packages/rules/src/soroban/tests.rs @@ -0,0 +1,187 @@ +#[cfg(test)] +mod tests { + use gasguard_rules::soroban::*; + + #[test] + fn test_soroban_struct_parsing() { + let source = r#" +#[contracttype] +pub struct Token { + pub admin: Address, + pub total_supply: u64, +} +"#; + + let lines: Vec<&str> = source.lines().collect(); + let parser = SorobanParser; + + if let Ok(Some(struct_def)) = parser.parse_single_struct(&lines[1..], 2) { + assert_eq!(struct_def.name, "Token"); + assert_eq!(struct_def.fields.len(), 2); + assert_eq!(struct_def.fields[0].name, "admin"); + assert_eq!(struct_def.fields[0].type_name, "Address"); + assert_eq!(struct_def.fields[1].name, "total_supply"); + assert_eq!(struct_def.fields[1].type_name, "u64"); + } else { + panic!("Failed to parse struct"); + } + } + + #[test] + fn test_soroban_function_parsing() { + let source = r#" + pub fn transfer(from: Address, to: Address, amount: u64) -> Result<(), Error> { + // Implementation here + } +"#; + + let lines: Vec<&str> = source.lines().collect(); + let parser = SorobanParser; + + if let Ok(Some(function)) = parser.parse_function(&lines, 1) { + assert_eq!(function.name, "transfer"); + assert_eq!(function.params.len(), 3); + assert_eq!(function.params[0].name, "from"); + assert_eq!(function.params[0].type_name, "Address"); + assert_eq!(function.return_type, Some("Result<(), Error>".to_string())); + } else { + panic!("Failed to parse function"); + } + } + + #[test] + fn test_field_visibility_detection() { + let parser = SorobanParser; + + // Test public field + let pub_field = parser.parse_field("pub admin: Address", 1).unwrap().unwrap(); + assert_eq!(pub_field.visibility, FieldVisibility::Public); + assert_eq!(pub_field.name, "admin"); + assert_eq!(pub_field.type_name, "Address"); + + // Test private field + let priv_field = parser.parse_field("counter: u64", 1).unwrap().unwrap(); + assert_eq!(priv_field.visibility, FieldVisibility::Private); + assert_eq!(priv_field.name, "counter"); + assert_eq!(priv_field.type_name, "u64"); + } + + #[test] + fn test_extract_between_parentheses() { + let parser = SorobanParser; + + let result = parser.extract_between_parentheses("fn test(param1: u64, param2: String)"); + assert_eq!(result, Some("param1: u64, param2: String".to_string())); + + let result = parser.extract_between_parentheses("no parens here"); + assert_eq!(result, None); + } + + #[test] + fn test_split_preserving_parentheses() { + let parser = SorobanParser; + + let result = parser.split_preserving_parentheses("param1: u64, param2: (u32, String)", ','); + assert_eq!(result.len(), 2); + assert_eq!(result[0], "param1: u64"); + assert_eq!(result[1], "param2: (u32, String)"); + } + + #[test] + fn test_soroban_analyzer_basic_checks() { + let contract = SorobanContract { + name: "TestContract".to_string(), + contract_types: vec![SorobanStruct { + name: "TestContract".to_string(), + fields: vec![ + SorobanField { + name: "admin".to_string(), + type_name: "Address".to_string(), + visibility: FieldVisibility::Public, + line_number: 3, + }, + SorobanField { + name: "unused_var".to_string(), + type_name: "String".to_string(), + visibility: FieldVisibility::Public, + line_number: 4, + } + ], + line_number: 2, + raw_definition: "".to_string(), + }], + implementations: vec![], + source: r#" +use soroban_sdk::{contract, contractimpl, contracttype, Address}; + +#[contracttype] +pub struct TestContract { + pub admin: Address, + pub unused_var: String, +} +"#.to_string(), + file_path: "test.rs".to_string(), + }; + + let violations = SorobanAnalyzer::analyze_contract(&contract); + + // Should detect unused variable + let unused_found = violations.iter().any(|v| + v.rule_name == "unused-state-variable" && v.variable_name == "unused_var" + ); + assert!(unused_found); + } + + #[test] + fn test_soroban_rule_engine_unused_variables_rule() { + let rule = UnusedStateVariablesRule::default(); + assert_eq!(rule.id(), "soroban-unused-state-variables"); + assert_eq!(rule.name(), "Unused State Variables"); + assert_eq!(rule.severity(), crate::ViolationSeverity::Warning); + assert!(rule.is_enabled()); + + let contract = SorobanContract { + name: "Test".to_string(), + contract_types: vec![SorobanStruct { + name: "Test".to_string(), + fields: vec![SorobanField { + name: "never_used".to_string(), + type_name: "u64".to_string(), + visibility: FieldVisibility::Public, + line_number: 1, + }], + line_number: 1, + raw_definition: "".to_string(), + }], + implementations: vec![], + source: "struct Test { never_used: u64 }".to_string(), + file_path: "test.rs".to_string(), + }; + + let violations = rule.apply(&contract); + assert!(!violations.is_empty()); + assert_eq!(violations[0].rule_name, "soroban-unused-state-variables"); + } + + #[test] + fn test_soroban_parse_error_handling() { + let parser = SorobanParser; + + // Test parsing invalid contract (missing #[contracttype]) + let invalid_source = r#" +struct Test { + field: u64, +} +"#; + + let result = parser.parse_contract(invalid_source, "invalid.rs"); + assert!(result.is_err()); + + match result.unwrap_err() { + SorobanParseError::MissingMacro(msg) => { + assert!(msg.contains("contract name")); + } + _ => panic!("Expected MissingMacro error"), + } + } +} \ No newline at end of file diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 6c72697..1bc5cd9 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -1,4 +1,5 @@ use gasguard_engine::{ContractScanner, ScanAnalyzer}; +use gasguard_rules::{SorobanParser, SorobanContract, SorobanAnalyzer, SorobanRuleEngine}; use std::path::Path; #[test] @@ -159,3 +160,238 @@ fn test_storage_savings_calculation() { assert_eq!(savings.estimated_savings_kb, 5.0); // 2 * 2.5 KB per variable assert!(savings.monthly_ledger_rent_savings > 0.0); } + +// New Soroban-specific tests + +#[test] +fn test_soroban_parser_basic_contract() { + let contract_code = r#" +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, Symbol}; + +#[contracttype] +pub struct TokenContract { + pub admin: Address, + pub total_supply: u64, + pub balances: Map, +} + +#[contractimpl] +impl TokenContract { + pub fn new(admin: Address, initial_supply: u64) -> Self { + let mut balances = Map::new(); + balances.set(admin, initial_supply); + + Self { + admin, + total_supply: initial_supply, + balances, + } + } + + pub fn transfer(env: Env, from: Address, to: Address, amount: u64) { + let from_balance = env.storage().instance().get(&from).unwrap_or(0); + let to_balance = env.storage().instance().get(&to).unwrap_or(0); + + env.storage().instance().set(&from, &(from_balance - amount)); + env.storage().instance().set(&to, &(to_balance + amount)); + } +} +"#; + + let contract = SorobanParser::parse_contract(contract_code, "token_contract.rs").unwrap(); + + assert_eq!(contract.name, "TokenContract"); + assert_eq!(contract.contract_types.len(), 1); + assert_eq!(contract.implementations.len(), 1); + + let contract_type = &contract.contract_types[0]; + assert_eq!(contract_type.name, "TokenContract"); + assert_eq!(contract_type.fields.len(), 3); + + let implementation = &contract.implementations[0]; + assert_eq!(implementation.functions.len(), 2); + assert_eq!(implementation.functions[0].name, "new"); + assert_eq!(implementation.functions[1].name, "transfer"); +} + +#[test] +fn test_soroban_analyzer_unused_variables() { + let contract_code = r#" +use soroban_sdk::{contract, contractimpl, contracttype, Address}; + +#[contracttype] +pub struct TestContract { + pub admin: Address, + pub unused_counter: u64, + pub active_flag: bool, +} + +#[contractimpl] +impl TestContract { + pub fn new(admin: Address) -> Self { + Self { + admin, + unused_counter: 0, + active_flag: true, + } + } + + pub fn is_active(&self) -> bool { + self.active_flag + } +} +"#; + + let contract = SorobanParser::parse_contract(contract_code, "test.rs").unwrap(); + let violations = SorobanAnalyzer::analyze_contract(&contract); + + let unused_found = violations.iter().any(|v| + v.rule_name == "unused-state-variable" && v.variable_name == "unused_counter" + ); + assert!(unused_found); +} + +#[test] +fn test_soroban_rule_engine_integration() { + let contract_code = r#" +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env}; + +#[contracttype] +pub struct BadContract { + admin: Address, + counter: u128, + unused_data: String, +} + +#[contractimpl] +impl BadContract { + pub fn new(admin: Address) -> Self { + Self { + admin, + counter: 0, + unused_data: "never_used".to_string(), + } + } + + pub fn increment(&mut self) { + self.counter += 1; + let expensive_vec = Vec::new(); + expensive_vec.push(1); + } +} +"#; + + let engine = SorobanRuleEngine::with_default_rules(); + let violations = engine.analyze(contract_code, "bad_contract.rs").unwrap(); + + // Should detect multiple violations: + // 1. Unused state variable + // 2. Inefficient integer type (u128) + // 3. Private contract field + // 4. Expensive string operation + // 5. Vec without capacity + + assert!(!violations.is_empty()); + assert!(violations.len() >= 4); + + let rule_names: Vec = violations.iter().map(|v| v.rule_name.clone()).collect(); + assert!(rule_names.contains(&"soroban-unused-state-variables".to_string())); + assert!(rule_names.contains(&"soroban-inefficient-integers".to_string())); + assert!(rule_names.contains(&"soroban-expensive-strings".to_string())); +} + +#[test] +fn test_soroban_scanner_direct_analysis() { + let scanner = ContractScanner::new(); + let contract_code = r#" +use soroban_sdk::{contract, contractimpl, contracttype, Address, Symbol}; + +#[contracttype] +pub struct EfficientContract { + pub owner: Address, + pub balance: u64, +} + +#[contractimpl] +impl EfficientContract { + pub fn new(owner: Address, initial_balance: u64) -> Result { + if initial_balance == 0 { + return Err(Error::InvalidAmount); + } + + Ok(Self { + owner, + balance: initial_balance, + }) + } + + pub fn deposit(&mut self, amount: u64) -> Result<(), Error> { + if amount == 0 { + return Err(Error::InvalidAmount); + } + self.balance += amount; + Ok(()) + } + + pub fn withdraw(&mut self, amount: u64) -> Result<(), Error> { + if amount == 0 { + return Err(Error::InvalidAmount); + } + if self.balance < amount { + return Err(Error::InsufficientBalance); + } + self.balance -= amount; + Ok(()) + } +} +"#; + + // Test direct Soroban scanning + let result = scanner.scan_soroban_content(contract_code, "efficient_contract.rs".to_string()).unwrap(); + + // This well-structured contract should have minimal violations + // Most issues should be informational rather than critical + let critical_violations: Vec<_> = result.violations.iter() + .filter(|v| matches!(v.severity, gasguard_rules::ViolationSeverity::Error | gasguard_rules::ViolationSeverity::Warning)) + .collect(); + + // Should have very few or no critical violations + assert!(critical_violations.len() <= 2); +} + +#[test] +fn test_language_detection_heuristics() { + use gasguard_engine::Language; + + // Test Soroban detection + let soroban_code = r#" +use soroban_sdk::{contract, contractimpl, contracttype}; +#[contracttype] +pub struct Test {} +#[contractimpl] +impl Test {} +"#; + + let detected = Language::from_content(soroban_code); + assert_eq!(detected, Some(Language::Soroban)); + + // Test Vyper detection + let vyper_code = r#" +# @version ^0.3.0 +interface Token: + def transfer(_to: address, _value: uint256): nonpayable +"#; + + let detected = Language::from_content(vyper_code); + assert_eq!(detected, Some(Language::Vyper)); + + // Test Rust detection + let rust_code = r#" +fn main() { + println!("Hello, world!"); +} +"#; + + let detected = Language::from_content(rust_code); + assert_eq!(detected, Some(Language::Rust)); +}