From 20c65f5d46de8bc7c91b3c30355bf92cb25c92cc Mon Sep 17 00:00:00 2001 From: Ebuka Moses Date: Tue, 24 Mar 2026 23:54:01 +0100 Subject: [PATCH 1/2] Custom lint for 'Instance' storage misuse --- .../sanctifier-cli/src/commands/analyze.rs | 47 ++- tooling/sanctifier-core/src/finding_codes.rs | 7 + tooling/sanctifier-core/src/lib.rs | 7 + .../src/rules/instance_storage.rs | 292 ++++++++++++++++++ tooling/sanctifier-core/src/rules/mod.rs | 2 + 5 files changed, 353 insertions(+), 2 deletions(-) create mode 100644 tooling/sanctifier-core/src/rules/instance_storage.rs diff --git a/tooling/sanctifier-cli/src/commands/analyze.rs b/tooling/sanctifier-cli/src/commands/analyze.rs index 8d77697..e305859 100644 --- a/tooling/sanctifier-cli/src/commands/analyze.rs +++ b/tooling/sanctifier-cli/src/commands/analyze.rs @@ -4,7 +4,7 @@ use crate::commands::webhook::{ use clap::Args; use colored::*; use sanctifier_core::finding_codes; -use sanctifier_core::{Analyzer, SanctifyConfig, SizeWarningLevel}; +use sanctifier_core::{Analyzer, InstanceStorageRisk, SanctifyConfig, SizeWarningLevel}; use serde_json; use std::fs; use std::path::{Path, PathBuf}; @@ -99,6 +99,7 @@ pub fn exec(args: AnalyzeArgs) -> anyhow::Result<()> { let mut smt_issues = Vec::new(); let mut sep41_checked_contracts = Vec::new(); let mut sep41_issues = Vec::new(); + let mut instance_storage_risks: Vec = Vec::new(); if path.is_dir() { walk_dir( @@ -119,6 +120,7 @@ pub fn exec(args: AnalyzeArgs) -> anyhow::Result<()> { &mut smt_issues, &mut sep41_checked_contracts, &mut sep41_issues, + &mut instance_storage_risks, )?; } else if path.extension().and_then(|s| s.to_str()) == Some("rs") { if let Ok(content) = fs::read_to_string(path) { @@ -146,6 +148,12 @@ pub fn exec(args: AnalyzeArgs) -> anyhow::Result<()> { sep41_issues.push(issue); } } + + let mut inst = analyzer.scan_instance_storage_risks(&content); + for i in &mut inst { + i.snippet = format!("{}:{}: {}", file_name, i.line, i.snippet); + } + instance_storage_risks.extend(inst); } } @@ -163,7 +171,8 @@ pub fn exec(args: AnalyzeArgs) -> anyhow::Result<()> { .map(|r| r.findings.len()) .sum::() + smt_issues.len() - + sep41_issues.len(); + + sep41_issues.len() + + instance_storage_risks.len(); let has_critical = !auth_gaps.is_empty() || panic_issues.iter().any(|p| p.issue_type == "panic!"); @@ -207,6 +216,7 @@ pub fn exec(args: AnalyzeArgs) -> anyhow::Result<()> { "smt_issues": smt_issues, "sep41_checked_contracts": sep41_checked_contracts, "sep41_issues": sep41_issues, + "instance_storage_risks": instance_storage_risks, "vulnerability_db_matches": vuln_matches, "vulnerability_db_version": vuln_db.version, "metadata": { @@ -229,6 +239,7 @@ pub fn exec(args: AnalyzeArgs) -> anyhow::Result<()> { "unhandled_results": unhandled_results.len(), "smt_issues": smt_issues.len(), "sep41_issues": sep41_issues.len(), + "instance_storage_risks": instance_storage_risks.len(), "has_critical": has_critical, "has_high": has_high, }, @@ -314,6 +325,12 @@ pub fn exec(args: AnalyzeArgs) -> anyhow::Result<()> { "expected_signature": issue.expected_signature, "actual_signature": issue.actual_signature, })).collect::>(), + "instance_storage_risks": instance_storage_risks.iter().map(|r| serde_json::json!({ + "code": finding_codes::INSTANCE_STORAGE_LARGE_DATA, + "line": r.line, + "message": r.message, + "snippet": r.snippet, + })).collect::>(), }, }); println!("{}", serde_json::to_string_pretty(&report)?); @@ -403,6 +420,24 @@ pub fn exec(args: AnalyzeArgs) -> anyhow::Result<()> { } } + if instance_storage_risks.is_empty() { + println!("{} No instance-storage large-data hints.", "✅".green()); + } else { + println!( + "\n{} Instance storage may be hosting large / per-user data!", + "⚠️".yellow() + ); + for risk in &instance_storage_risks { + println!( + " {} [{}] line {}", + "->".yellow(), + finding_codes::INSTANCE_STORAGE_LARGE_DATA.bold(), + risk.line + ); + println!(" {}", risk.message); + } + } + if !event_issues.is_empty() { println!( "\n{} Found Event Consistency/Optimization issues!", @@ -584,6 +619,7 @@ fn walk_dir( smt_issues: &mut Vec, sep41_checked_contracts: &mut Vec, sep41_issues: &mut Vec, + instance_storage_risks: &mut Vec, ) -> anyhow::Result<()> { for entry in fs::read_dir(dir)? { let entry = entry?; @@ -617,6 +653,7 @@ fn walk_dir( smt_issues, sep41_checked_contracts, sep41_issues, + instance_storage_risks, )?; } else if path.extension().and_then(|s| s.to_str()) == Some("rs") { if let Ok(content) = fs::read_to_string(&path) { @@ -696,6 +733,12 @@ fn walk_dir( sep41_issues.push(issue); } } + + let mut inst = analyzer.scan_instance_storage_risks(&content); + for i in &mut inst { + i.snippet = format!("{}:{}: {}", file_name, i.line, i.snippet); + } + instance_storage_risks.extend(inst); } } } diff --git a/tooling/sanctifier-core/src/finding_codes.rs b/tooling/sanctifier-core/src/finding_codes.rs index 760ad0c..e2e369f 100644 --- a/tooling/sanctifier-core/src/finding_codes.rs +++ b/tooling/sanctifier-core/src/finding_codes.rs @@ -12,6 +12,7 @@ pub const UNHANDLED_RESULT: &str = "S009"; pub const UPGRADE_RISK: &str = "S010"; pub const SMT_INVARIANT_VIOLATION: &str = "S011"; pub const SEP41_INTERFACE_DEVIATION: &str = "S012"; +pub const INSTANCE_STORAGE_LARGE_DATA: &str = "S013"; #[derive(Debug, Clone, Serialize)] pub struct FindingCode { @@ -82,6 +83,11 @@ pub fn all_finding_codes() -> Vec { category: "token_interface", description: "SEP-41 token interface compatibility or authorization deviation", }, + FindingCode { + code: INSTANCE_STORAGE_LARGE_DATA, + category: "storage_limits", + description: "Large or per-user data stored in instance storage instead of persistent/temporary", + }, ] } @@ -108,5 +114,6 @@ mod tests { assert!(codes.iter().any(|c| c.code == UNSAFE_PATTERN)); assert!(codes.iter().any(|c| c.code == CUSTOM_RULE_MATCH)); assert!(codes.iter().any(|c| c.code == SEP41_INTERFACE_DEVIATION)); + assert!(codes.iter().any(|c| c.code == INSTANCE_STORAGE_LARGE_DATA)); } } diff --git a/tooling/sanctifier-core/src/lib.rs b/tooling/sanctifier-core/src/lib.rs index 2121033..2b9e752 100644 --- a/tooling/sanctifier-core/src/lib.rs +++ b/tooling/sanctifier-core/src/lib.rs @@ -13,6 +13,7 @@ use syn::spanned::Spanned; use syn::visit::{self, Visit}; use syn::{parse_str, Fields, File, Item, Meta, Type}; +pub use rules::instance_storage::InstanceStorageRisk; pub use rules::{Rule, RuleRegistry, RuleViolation, Severity}; pub use sep41::{Sep41Issue, Sep41IssueKind, Sep41VerificationReport}; @@ -1044,6 +1045,11 @@ impl Analyzer { with_panic_guard(|| self.scan_unhandled_results_impl(source)) } + /// Heuristic: `instance().set` with map/vec/string/profile-like payloads (see SEP/Soroban storage guidance). + pub fn scan_instance_storage_risks(&self, source: &str) -> Vec { + with_panic_guard(|| crate::rules::instance_storage::scan_instance_storage_risks(source)) + } + fn scan_unhandled_results_impl(&self, source: &str) -> Vec { let file = match parse_str::(source) { Ok(f) => f, @@ -2591,6 +2597,7 @@ mod tests { let registry = RuleRegistry::default(); let rules = registry.available_rules(); assert!(rules.contains(&"auth_gap")); + assert!(rules.contains(&"instance_storage_large_data")); assert!(rules.contains(&"ledger_size")); assert!(rules.contains(&"panic_detection")); assert!(rules.contains(&"arithmetic_overflow")); diff --git a/tooling/sanctifier-core/src/rules/instance_storage.rs b/tooling/sanctifier-core/src/rules/instance_storage.rs new file mode 100644 index 0000000..ccc832f --- /dev/null +++ b/tooling/sanctifier-core/src/rules/instance_storage.rs @@ -0,0 +1,292 @@ +//! Heuristic: storing large or user-scoped datasets in instance storage balloons the single +//! instance ledger entry and increases rent / IO costs; prefer `persistent()` (or `temporary()`) +//! with keyed entries. + +use crate::rules::{Rule, RuleViolation, Severity}; +use syn::spanned::Spanned; +use syn::visit::{self, Visit}; +use syn::{parse_str, Expr, ExprMethodCall, File}; + +pub struct InstanceStorageRule; + +impl InstanceStorageRule { + pub fn new() -> Self { + Self + } +} + +impl Default for InstanceStorageRule { + fn default() -> Self { + Self::new() + } +} + +impl Rule for InstanceStorageRule { + fn name(&self) -> &str { + "instance_storage_large_data" + } + + fn description(&self) -> &str { + "Flags values that look like large datasets or user profiles stored via instance storage" + } + + fn check(&self, source: &str) -> Vec { + scan_instance_storage_risks(source) + .into_iter() + .map(|r| { + RuleViolation::new( + self.name(), + Severity::Warning, + r.message, + format!("{}", r.line), + ) + .with_suggestion( + "Use env.storage().persistent() (or temporary()) with a narrow key, so large or \ + per-user data does not grow the single instance entry." + .to_string(), + ) + }) + .collect() + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } +} + +/// Public finding type for CLI / JSON (line + detail). +#[derive(Debug, Clone, serde::Serialize)] +pub struct InstanceStorageRisk { + pub line: usize, + pub message: String, + pub snippet: String, +} + +struct InstanceStorageVisitor { + risks: Vec, +} + +impl InstanceStorageVisitor { + fn consider_set(&mut self, node: &ExprMethodCall) { + if node.method != "set" || node.args.len() < 2 { + return; + } + if !receiver_chain_contains_instance(&node.receiver) { + return; + } + let key = &node.args[0]; + let val = &node.args[1]; + if benign_instance_value(val) && !key_suggests_user_scale_data(key) { + return; + } + if value_looks_like_large_payload(val) || key_suggests_user_scale_data(key) { + let line = node.span().start().line; + let snippet = quote::quote!(#node).to_string(); + let mut reason = String::new(); + if value_looks_like_large_payload(val) { + reason.push_str( + "value looks like a map, vector, string/bytes, or profile-like struct", + ); + } + if key_suggests_user_scale_data(key) { + if !reason.is_empty() { + reason.push_str("; "); + } + reason.push_str("key suggests per-user / profile-scale data"); + } + let message = format!( + "Instance storage `.set` may pin {reason} in the contract instance entry (high ledger cost). \ + Prefer persistent storage for large or per-user datasets." + ); + self.risks.push(InstanceStorageRisk { + line, + message, + snippet, + }); + } + } +} + +impl<'ast> Visit<'ast> for InstanceStorageVisitor { + fn visit_expr_method_call(&mut self, node: &'ast ExprMethodCall) { + self.consider_set(node); + visit::visit_expr_method_call(self, node); + } +} + +fn receiver_chain_contains_instance(expr: &Expr) -> bool { + let s = quote::quote!(#expr) + .to_string() + .chars() + .filter(|c| !c.is_whitespace()) + .collect::(); + s.contains(".instance()") +} + +fn peel_refs(expr: &Expr) -> &Expr { + match expr { + Expr::Reference(r) => peel_refs(&r.expr), + Expr::Group(g) => peel_refs(&g.expr), + _ => expr, + } +} + +fn expr_quote_compact(expr: &Expr) -> String { + quote::quote!(#expr) + .to_string() + .chars() + .filter(|c| !c.is_whitespace()) + .collect() +} + +fn key_suggests_user_scale_data(key: &Expr) -> bool { + let k = expr_quote_compact(key).to_lowercase(); + k.contains("profile") + || k.contains("user") + || k.contains("member") + || k.contains("account") + || k.contains("metadata") + || k.contains("dataset") + || k.contains("record") +} + +fn value_looks_like_large_payload(val: &Expr) -> bool { + let inner = peel_refs(val); + let compact = expr_quote_compact(inner); + if compact.contains("Map<") + || compact.contains("Vec<") + || compact.contains("BytesN") + || (compact.contains("Bytes") && !compact.contains("BytesLit")) + { + return true; + } + if let Expr::Path(p) = inner { + if let Some(seg) = p.path.segments.last() { + let id = seg.ident.to_string(); + if id == "String" || id == "Bytes" { + return true; + } + } + } + if let Expr::Struct(s) = inner { + let name_lc = s + .path + .segments + .last() + .map(|seg| seg.ident.to_string().to_lowercase()) + .unwrap_or_default(); + if name_lc.contains("profile") + || name_lc.contains("user") + || name_lc.contains("metadata") + || name_lc.contains("record") + { + return true; + } + } + let lc = compact.to_lowercase(); + lc.contains("profile") + || lc.contains("userledger") + || lc.contains("userdata") +} + +/// Small configuration / token values typically kept in instance storage — skip those. +fn benign_instance_value(val: &Expr) -> bool { + let inner = peel_refs(val); + match inner { + Expr::Lit(_) => return true, + Expr::Path(p) => { + if let Some(seg) = p.path.segments.last() { + let id = seg.ident.to_string(); + if matches!( + id.as_str(), + "MAX" | "MIN" | "DECIMALS" | "NAME" | "SYMBOL" | "ADMIN" + ) { + return true; + } + if matches!(id.as_str(), "i128" | "u32" | "u64" | "i64" | "bool" | "u128") { + return true; + } + } + } + Expr::Field(_) => return true, + Expr::Call(c) => { + if let Expr::Path(pp) = &*c.func { + if pp.path.is_ident("symbol_short") { + return true; + } + } + } + _ => {} + } + let c = expr_quote_compact(inner); + if c.len() < 48 && !c.contains("Map") && !c.contains("Vec") { + if c.contains("DataKey::") || c.contains("Symbol::") || c.contains("Address::") { + return true; + } + } + false +} + +pub fn scan_instance_storage_risks(source: &str) -> Vec { + let Ok(file) = parse_str::(source) else { + return vec![]; + }; + let mut v = InstanceStorageVisitor { risks: vec![] }; + v.visit_file(&file); + v.risks +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn flags_map_in_instance_storage() { + let src = r#" + use soroban_sdk::{Env, Map, Address, symbol_short}; + #[contractimpl] + impl C { + pub fn bad(e: Env) { + e.storage().instance().set( + &symbol_short!("idx"), + &Map::::new(&e), + ); + } + } + "#; + let r = scan_instance_storage_risks(src); + assert!(!r.is_empty(), "{r:?}"); + } + + #[test] + fn flags_profile_key_with_instance_storage() { + let src = r#" + use soroban_sdk::{Env, String, symbol_short}; + #[contractimpl] + impl C { + pub fn bad(e: Env, name: String) { + e.storage().instance().set(&symbol_short!("user_profile"), &name); + } + } + "#; + let r = scan_instance_storage_risks(src); + assert!(!r.is_empty(), "{r:?}"); + } + + #[test] + fn skips_small_datakey_scalar_in_instance() { + let src = r#" + use soroban_sdk::{Env, symbol_short}; + #[contracttype] + enum DataKey { Admin } + #[contractimpl] + impl C { + pub fn ok(e: Env, a: soroban_sdk::Address) { + e.storage().instance().set(&DataKey::Admin, &a); + } + } + "#; + let r = scan_instance_storage_risks(src); + assert!(r.is_empty(), "{r:?}"); + } +} diff --git a/tooling/sanctifier-core/src/rules/mod.rs b/tooling/sanctifier-core/src/rules/mod.rs index 4b8a664..263fe85 100644 --- a/tooling/sanctifier-core/src/rules/mod.rs +++ b/tooling/sanctifier-core/src/rules/mod.rs @@ -1,5 +1,6 @@ pub mod arithmetic_overflow; pub mod auth_gap; +pub mod instance_storage; pub mod ledger_size; pub mod panic_detection; pub mod unhandled_result; @@ -111,6 +112,7 @@ impl RuleRegistry { pub fn with_default_rules() -> Self { let mut registry = Self::new(); registry.register(auth_gap::AuthGapRule::new()); + registry.register(instance_storage::InstanceStorageRule::new()); registry.register(ledger_size::LedgerSizeRule::new()); registry.register(panic_detection::PanicDetectionRule::new()); registry.register(arithmetic_overflow::ArithmeticOverflowRule::new()); From 73e4c0a88741d668b391c8a72e18d6db18fed637 Mon Sep 17 00:00:00 2001 From: Ebuka Moses Date: Wed, 25 Mar 2026 00:06:18 +0100 Subject: [PATCH 2/2] Interactive Contract Dependency Graph --- .../ContractInteractionVisualizer.tsx | 452 +++++++++++ .../components/EnhancedContractVisualizer.tsx | 706 ++++++++++++++++++ .../README_ContractVisualization.md | 151 ++++ frontend/app/dashboard/page.tsx | 6 + frontend/app/lib/contractAnalyzer.ts | 519 +++++++++++++ frontend/app/visualization/page.tsx | 22 + 6 files changed, 1856 insertions(+) create mode 100644 frontend/app/components/ContractInteractionVisualizer.tsx create mode 100644 frontend/app/components/EnhancedContractVisualizer.tsx create mode 100644 frontend/app/components/README_ContractVisualization.md create mode 100644 frontend/app/lib/contractAnalyzer.ts create mode 100644 frontend/app/visualization/page.tsx diff --git a/frontend/app/components/ContractInteractionVisualizer.tsx b/frontend/app/components/ContractInteractionVisualizer.tsx new file mode 100644 index 0000000..ce1987c --- /dev/null +++ b/frontend/app/components/ContractInteractionVisualizer.tsx @@ -0,0 +1,452 @@ +'use client'; + +import React, { useEffect, useRef, useState } from 'react'; + +interface Contract { + id: string; + name: string; + type: 'soroban' | 'rust'; + functions: Function[]; + address?: string; +} + +interface Function { + name: string; + visibility: 'public' | 'private'; + calls: Call[]; +} + +interface Call { + targetContract: string; + functionName: string; + type: 'internal' | 'external'; + line?: number; +} + +interface InteractionData { + contracts: Contract[]; + interactions: Interaction[]; +} + +interface Interaction { + from: string; + to: string; + functionName: string; + type: 'internal' | 'external'; + frequency: number; +} + +const ContractInteractionVisualizer: React.FC = () => { + const canvasRef = useRef(null); + const [interactionData, setInteractionData] = useState(null); + const [selectedContract, setSelectedContract] = useState(null); + const [hoveredInteraction, setHoveredInteraction] = useState(null); + const [filter, setFilter] = useState<'all' | 'internal' | 'external'>('all'); + + // Mock data based on the contract analysis + const mockInteractionData: InteractionData = { + contracts: [ + { + id: 'my-contract', + name: 'My Contract', + type: 'soroban', + functions: [ + { + name: 'handle_cross_contract_message', + visibility: 'public', + calls: [ + { targetContract: 'runtime-guard-wrapper', functionName: 'execute_guarded', type: 'external' }, + { targetContract: 'my-contract', functionName: 'handle_transfer', type: 'internal' }, + { targetContract: 'my-contract', functionName: 'handle_query', type: 'internal' }, + { targetContract: 'my-contract', functionName: 'handle_callback', type: 'internal' } + ] + } + ] + }, + { + id: 'runtime-guard-wrapper', + name: 'Runtime Guard Wrapper', + type: 'soroban', + functions: [ + { + name: 'execute_guarded', + visibility: 'public', + calls: [ + { targetContract: 'runtime-guard-wrapper', functionName: 'pre_execution_guards', type: 'internal' }, + { targetContract: 'runtime-guard-wrapper', functionName: 'execute_with_monitoring', type: 'internal' }, + { targetContract: 'runtime-guard-wrapper', functionName: 'post_execution_guards', type: 'internal' } + ] + }, + { + name: 'pre_execution_guards', + visibility: 'private', + calls: [ + { targetContract: 'runtime-guard-wrapper', functionName: 'validate_storage_integrity', type: 'internal' } + ] + } + ] + }, + { + id: 'vulnerable-contract', + name: 'Vulnerable Contract', + type: 'soroban', + functions: [ + { + name: 'set_admin', + visibility: 'public', + calls: [] + }, + { + name: 'set_admin_secure', + visibility: 'public', + calls: [] + } + ] + }, + { + id: 'amm-pool', + name: 'AMM Pool', + type: 'rust', + functions: [ + { + name: 'calculate_swap_output', + visibility: 'public', + calls: [] + }, + { + name: 'calculate_liquidity_mint', + visibility: 'public', + calls: [] + } + ] + }, + { + id: 'reentrancy-guard', + name: 'Reentrancy Guard', + type: 'soroban', + functions: [ + { + name: 'protected_function', + visibility: 'public', + calls: [ + { targetContract: 'reentrancy-guard', functionName: 'internal_logic', type: 'internal' } + ] + } + ] + } + ], + interactions: [ + { from: 'my-contract', to: 'runtime-guard-wrapper', functionName: 'execute_guarded', type: 'external', frequency: 5 }, + { from: 'runtime-guard-wrapper', to: 'runtime-guard-wrapper', functionName: 'pre_execution_guards', type: 'internal', frequency: 10 }, + { from: 'runtime-guard-wrapper', to: 'runtime-guard-wrapper', functionName: 'post_execution_guards', type: 'internal', frequency: 10 }, + { from: 'my-contract', to: 'my-contract', functionName: 'handle_transfer', type: 'internal', frequency: 8 }, + { from: 'my-contract', to: 'my-contract', functionName: 'handle_query', type: 'internal', frequency: 6 }, + { from: 'reentrancy-guard', to: 'reentrancy-guard', functionName: 'internal_logic', type: 'internal', frequency: 4 } + ] + }; + + useEffect(() => { + setInteractionData(mockInteractionData); + }, []); + + useEffect(() => { + if (interactionData && canvasRef.current) { + drawVisualization(); + } + }, [interactionData, selectedContract, filter]); + + const drawVisualization = () => { + const canvas = canvasRef.current; + if (!canvas || !interactionData) return; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + // Set canvas size + canvas.width = canvas.offsetWidth; + canvas.height = canvas.offsetHeight; + + // Clear canvas + ctx.clearRect(0, 0, canvas.width, canvas.height); + + // Position contracts in a circle + const centerX = canvas.width / 2; + const centerY = canvas.height / 2; + const radius = Math.min(canvas.width, canvas.height) * 0.3; + + const contractPositions = new Map(); + + interactionData.contracts.forEach((contract, index) => { + const angle = (index * 2 * Math.PI) / interactionData.contracts.length; + const x = centerX + radius * Math.cos(angle); + const y = centerY + radius * Math.sin(angle); + contractPositions.set(contract.id, { x, y }); + }); + + // Draw interactions + const filteredInteractions = interactionData.interactions.filter( + interaction => filter === 'all' || interaction.type === filter + ); + + filteredInteractions.forEach(interaction => { + const from = contractPositions.get(interaction.from); + const to = contractPositions.get(interaction.to); + + if (from && to) { + ctx.beginPath(); + ctx.moveTo(from.x, from.y); + + // Draw curved lines for self-calls + if (interaction.from === interaction.to) { + const controlX = from.x + 50; + const controlY = from.y - 50; + ctx.quadraticCurveTo(controlX, controlY, to.x, to.y); + } else { + ctx.lineTo(to.x, to.y); + } + + // Style based on interaction type + if (interaction.type === 'external') { + ctx.strokeStyle = '#ef4444'; // red for external + ctx.lineWidth = 2 + interaction.frequency * 0.5; + } else { + ctx.strokeStyle = '#3b82f6'; // blue for internal + ctx.lineWidth = 1 + interaction.frequency * 0.3; + } + + ctx.stroke(); + + // Draw arrow + const angle = Math.atan2(to.y - from.y, to.x - from.x); + const arrowLength = 10; + const arrowAngle = Math.PI / 6; + + ctx.beginPath(); + ctx.moveTo(to.x, to.y); + ctx.lineTo( + to.x - arrowLength * Math.cos(angle - arrowAngle), + to.y - arrowLength * Math.sin(angle - arrowAngle) + ); + ctx.moveTo(to.x, to.y); + ctx.lineTo( + to.x - arrowLength * Math.cos(angle + arrowAngle), + to.y - arrowLength * Math.sin(angle + arrowAngle) + ); + ctx.stroke(); + } + }); + + // Draw contract nodes + interactionData.contracts.forEach(contract => { + const pos = contractPositions.get(contract.id); + if (!pos) return; + + // Node styling + ctx.beginPath(); + ctx.arc(pos.x, pos.y, 30, 0, 2 * Math.PI); + + if (selectedContract === contract.id) { + ctx.fillStyle = '#fbbf24'; // yellow for selected + } else if (contract.type === 'soroban') { + ctx.fillStyle = '#10b981'; // green for soroban + } else { + ctx.fillStyle = '#8b5cf6'; // purple for rust + } + + ctx.fill(); + ctx.strokeStyle = '#1f2937'; + ctx.lineWidth = 2; + ctx.stroke(); + + // Draw contract name + ctx.fillStyle = '#1f2937'; + ctx.font = '12px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText(contract.name, pos.x, pos.y + 50); + }); + }; + + const getContractDetails = (contractId: string) => { + if (!interactionData) return null; + return interactionData.contracts.find(c => c.id === contractId); + }; + + const getInteractionStats = () => { + if (!interactionData) return { total: 0, internal: 0, external: 0 }; + + const stats = interactionData.interactions.reduce( + (acc, interaction) => { + acc.total++; + if (interaction.type === 'internal') acc.internal++; + else acc.external++; + return acc; + }, + { total: 0, internal: 0, external: 0 } + ); + + return stats; + }; + + if (!interactionData) { + return ( +
+
Loading contract interaction data...
+
+ ); + } + + const stats = getInteractionStats(); + + return ( +
+
+

Contract Interaction Visualizer

+

+ Visualize how contracts interact with each other, distinguishing between internal and external calls. +

+
+ + {/* Stats Cards */} +
+
+
Total Interactions
+
{stats.total}
+
+
+
Internal Calls
+
{stats.internal}
+
+
+
External Calls
+
{stats.external}
+
+
+ + {/* Filter Controls */} +
+ + + +
+ + {/* Main Visualization */} +
+
+
+ { + // Handle hover interactions for displaying tooltips + const rect = canvasRef.current?.getBoundingClientRect(); + if (rect) { + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + // Add logic to detect if hovering over an interaction + } + }} + /> +
+ + {/* Legend */} +
+
+
+ Soroban Contract +
+
+
+ Rust Module +
+
+
+ Internal Call +
+
+
+ External Call +
+
+
+ + {/* Contract Details Panel */} +
+
+

Contract Details

+ + {selectedContract ? ( +
+ {(() => { + const contract = getContractDetails(selectedContract); + return contract ? ( +
+
+

{contract.name}

+

Type: {contract.type}

+

Functions: {contract.functions.length}

+
+ +
+
Functions:
+ {contract.functions.map(func => ( +
+
+ {func.name} + + {func.visibility} + +
+ {func.calls.length > 0 && ( +
+ Calls: {func.calls.length} ({func.calls.filter(c => c.type === 'internal').length} internal, {func.calls.filter(c => c.type === 'external').length} external) +
+ )} +
+ ))} +
+
+ ) : null; + })()} +
+ ) : ( +

Click on a contract node to view details

+ )} +
+
+
+
+ ); +}; + +export default ContractInteractionVisualizer; diff --git a/frontend/app/components/EnhancedContractVisualizer.tsx b/frontend/app/components/EnhancedContractVisualizer.tsx new file mode 100644 index 0000000..83d969c --- /dev/null +++ b/frontend/app/components/EnhancedContractVisualizer.tsx @@ -0,0 +1,706 @@ +'use client'; + +import React, { useEffect, useRef, useState, useCallback } from 'react'; +import { ContractAnalyzer, InteractionGraph, ParsedContract, ContractInteraction } from '../lib/contractAnalyzer'; + +interface VisualizationNode { + id: string; + name: string; + x: number; + y: number; + type: 'soroban' | 'rust'; + level: number; + connections: number; +} + +interface VisualizationEdge { + from: string; + to: string; + type: 'internal' | 'external'; + weight: number; + label: string; +} + +const EnhancedContractVisualizer: React.FC = () => { + const canvasRef = useRef(null); + const [analyzer] = useState(() => new ContractAnalyzer()); + const [interactionGraph, setInteractionGraph] = useState(null); + const [nodes, setNodes] = useState([]); + const [edges, setEdges] = useState([]); + const [selectedNode, setSelectedNode] = useState(null); + const [hoveredNode, setHoveredNode] = useState(null); + const [filter, setFilter] = useState<'all' | 'internal' | 'external'>('all'); + const [layout, setLayout] = useState<'circular' | 'force' | 'hierarchical'>('circular'); + const [isLoading, setIsLoading] = useState(false); + const [stats, setStats] = useState(null); + + // Load and analyze contract data + useEffect(() => { + loadContractData(); + }, []); + + // Update visualization when data changes + useEffect(() => { + if (interactionGraph) { + updateVisualization(); + } + }, [interactionGraph, filter, layout]); + + const loadContractData = async () => { + setIsLoading(true); + try { + // In a real implementation, this would fetch actual contract files + // For now, we'll use mock data based on the actual contracts found + const mockContractFiles = [ + { + path: '/contracts/my-contract/src/lib.rs', + content: ` +use borsh::{BorshDeserialize, BorshSerialize}; + +#[derive(Debug, BorshSerialize, BorshDeserialize, Clone)] +pub struct CrossContractMessage { + pub sender: [u8; 32], + pub method_id: u8, + pub payload: Vec, + pub nonce: u64, +} + +pub fn handle_cross_contract_message(raw: &[u8]) -> Result { + let msg = CrossContractMessage::try_from_slice(raw) + .map_err(|_| ContractError::DeserializationFailed)?; + match msg.method_id { + 0 => handle_transfer(&msg.payload), + 1 => handle_query(&msg.payload), + 2 => handle_callback(&msg.payload), + _ => Err(ContractError::UnknownMethod), + } +} + +fn handle_transfer(payload: &[u8]) -> Result { + if payload.len() < 8 { + return Err(ContractError::InvalidPayload("too short".into())); + } + let amount = u64::from_le_bytes(payload[0..8].try_into().unwrap()); + let fee = amount.checked_mul(3).ok_or(ContractError::OverflowDetected)?; + Ok(format!("transfer: amount={amount}, fee={fee}")) +} + ` + }, + { + path: '/contracts/runtime-guard-wrapper/src/lib.rs', + content: ` +use soroban_sdk::{contract, contractimpl, Address, Env, Error, IntoVal, Symbol, Val, Vec}; + +#[contract] +pub struct RuntimeGuardWrapper; + +#[contractimpl] +impl RuntimeGuardWrapper { + pub fn execute_guarded(env: Env, function_name: Symbol, args: Vec) -> Result { + Self::pre_execution_guards(env.clone())?; + let result = Self::execute_with_monitoring(env.clone(), &function_name, &args)?; + Self::post_execution_guards(env.clone())?; + Self::log_execution(env.clone(), &function_name, &result); + Ok(result) + } + + fn pre_execution_guards(env: Env) -> Result<(), Error> { + Self::validate_storage_integrity(env.clone())?; + Ok(()) + } + + fn execute_with_monitoring(env: Env, function_name: &Symbol, _args: &Vec) -> Result { + let start_tick = env.ledger().timestamp(); + let result = Val::default(); + Self::record_metrics(env, start_tick); + Ok(result) + } +} + ` + }, + { + path: '/contracts/vulnerable-contract/src/lib.rs', + content: ` +use soroban_sdk::{contract, contractimpl, symbol_short, Env, Symbol}; + +#[contract] +pub struct VulnerableContract; + +#[contractimpl] +impl VulnerableContract { + pub fn set_admin(env: Env, new_admin: Symbol) { + env.storage() + .instance() + .set(&symbol_short!("admin"), &new_admin); + } + + pub fn set_admin_secure(env: Env, new_admin: Symbol) { + let _admin: Symbol = env + .storage() + .instance() + .get(&symbol_short!("admin")) + .expect("Admin not set"); + env.storage() + .instance() + .set(&symbol_short!("admin"), &new_admin); + } +} + ` + } + ]; + + const graph = await analyzer.analyzeContracts(mockContractFiles); + setInteractionGraph(graph); + + const analyzerStats = analyzer.getInteractionStats(); + setStats(analyzerStats); + } catch (error) { + console.error('Error loading contract data:', error); + } finally { + setIsLoading(false); + } + }; + + const updateVisualization = useCallback(() => { + if (!interactionGraph) return; + + const newNodes = createNodes(interactionGraph); + const newEdges = createEdges(interactionGraph); + + setNodes(newNodes); + setEdges(newEdges); + }, [interactionGraph, filter]); + + const createNodes = (graph: InteractionGraph): VisualizationNode[] => { + return graph.contracts.map((contract, index) => { + const connections = graph.interactions.filter( + interaction => interaction.fromContract === contract.id || interaction.toContract === contract.id + ).length; + + return { + id: contract.id, + name: contract.name, + x: 0, + y: 0, + type: contract.type, + level: 0, + connections + }; + }); + }; + + const createEdges = (graph: InteractionGraph): VisualizationEdge[] => { + return graph.interactions + .filter(interaction => filter === 'all' || interaction.type === filter) + .map(interaction => ({ + from: interaction.fromContract, + to: interaction.toContract, + type: interaction.type, + weight: interaction.frequency, + label: interaction.toFunction + })); + }; + + const applyLayout = useCallback(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const width = canvas.width; + const height = canvas.height; + const centerX = width / 2; + const centerY = height / 2; + + const updatedNodes = [...nodes]; + + switch (layout) { + case 'circular': + const radius = Math.min(width, height) * 0.3; + updatedNodes.forEach((node, index) => { + const angle = (index * 2 * Math.PI) / updatedNodes.length; + node.x = centerX + radius * Math.cos(angle); + node.y = centerY + radius * Math.sin(angle); + }); + break; + + case 'force': + // Simple force-directed layout + const iterations = 50; + const k = Math.sqrt((width * height) / updatedNodes.length) * 0.5; + + // Initialize random positions + updatedNodes.forEach(node => { + node.x = Math.random() * width; + node.y = Math.random() * height; + }); + + for (let iter = 0; iter < iterations; iter++) { + // Repulsive forces between all nodes + for (let i = 0; i < updatedNodes.length; i++) { + for (let j = i + 1; j < updatedNodes.length; j++) { + const dx = updatedNodes[j].x - updatedNodes[i].x; + const dy = updatedNodes[j].y - updatedNodes[i].y; + const distance = Math.sqrt(dx * dx + dy * dy) || 1; + const force = (k * k) / distance; + + updatedNodes[i].x -= (dx / distance) * force; + updatedNodes[i].y -= (dy / distance) * force; + updatedNodes[j].x += (dx / distance) * force; + updatedNodes[j].y += (dy / distance) * force; + } + } + + // Attractive forces for connected nodes + edges.forEach(edge => { + const sourceNode = updatedNodes.find(n => n.id === edge.from); + const targetNode = updatedNodes.find(n => n.id === edge.to); + + if (sourceNode && targetNode) { + const dx = targetNode.x - sourceNode.x; + const dy = targetNode.y - sourceNode.y; + const distance = Math.sqrt(dx * dx + dy * dy) || 1; + const force = (distance * distance) / k; + + sourceNode.x += (dx / distance) * force * 0.5; + sourceNode.y += (dy / distance) * force * 0.5; + targetNode.x -= (dx / distance) * force * 0.5; + targetNode.y -= (dy / distance) * force * 0.5; + } + }); + + // Keep nodes within canvas bounds + updatedNodes.forEach(node => { + node.x = Math.max(30, Math.min(width - 30, node.x)); + node.y = Math.max(30, Math.min(height - 30, node.y)); + }); + } + break; + + case 'hierarchical': + // Simple hierarchical layout based on connections + const levels = new Map(); + const visited = new Set(); + + // Find root nodes (nodes with only outgoing edges) + updatedNodes.forEach(node => { + const hasIncoming = edges.some(edge => edge.to === node.id); + if (!hasIncoming) { + levels.set(node.id, 0); + } + }); + + // Assign levels based on connections + let changed = true; + while (changed) { + changed = false; + edges.forEach(edge => { + if (levels.has(edge.from) && !levels.has(edge.to)) { + levels.set(edge.to, (levels.get(edge.from) || 0) + 1); + changed = true; + } + }); + } + + // Position nodes by level + const nodesByLevel = new Map(); + updatedNodes.forEach(node => { + const level = levels.get(node.id) || 0; + if (!nodesByLevel.has(level)) { + nodesByLevel.set(level, []); + } + nodesByLevel.get(level)!.push(node); + }); + + nodesByLevel.forEach((nodesAtLevel, level) => { + const levelY = 50 + level * (height - 100) / Math.max(nodesByLevel.size, 1); + const levelWidth = width / (nodesAtLevel.length + 1); + + nodesAtLevel.forEach((node, index) => { + node.x = levelWidth * (index + 1); + node.y = levelY; + node.level = level; + }); + }); + break; + } + + setNodes(updatedNodes); + }, [nodes, edges, layout]); + + useEffect(() => { + applyLayout(); + }, [applyLayout]); + + // Draw visualization + useEffect(() => { + drawVisualization(); + }, [nodes, edges, selectedNode, hoveredNode]); + + const drawVisualization = () => { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + // Set canvas size + canvas.width = canvas.offsetWidth; + canvas.height = canvas.offsetHeight; + + // Clear canvas + ctx.clearRect(0, 0, canvas.width, canvas.height); + + // Draw edges + edges.forEach(edge => { + const fromNode = nodes.find(n => n.id === edge.from); + const toNode = nodes.find(n => n.id === edge.to); + + if (fromNode && toNode) { + ctx.beginPath(); + ctx.moveTo(fromNode.x, fromNode.y); + + // Draw curved line for self-loops + if (edge.from === edge.to) { + const controlX = fromNode.x + 50; + const controlY = fromNode.y - 50; + ctx.quadraticCurveTo(controlX, controlY, toNode.x, toNode.y); + } else { + ctx.lineTo(toNode.x, toNode.y); + } + + // Style based on edge type + if (edge.type === 'external') { + ctx.strokeStyle = '#ef4444'; // red for external + ctx.lineWidth = Math.min(5, 1 + edge.weight * 0.5); + } else { + ctx.strokeStyle = '#3b82f6'; // blue for internal + ctx.lineWidth = Math.min(3, 1 + edge.weight * 0.3); + } + + ctx.globalAlpha = selectedNode && selectedNode !== edge.from && selectedNode !== edge.to ? 0.3 : 1; + ctx.stroke(); + ctx.globalAlpha = 1; + + // Draw arrow + if (edge.from !== edge.to) { + const angle = Math.atan2(toNode.y - fromNode.y, toNode.x - fromNode.x); + const arrowLength = 10; + const arrowAngle = Math.PI / 6; + const arrowX = toNode.x - 30 * Math.cos(angle); + const arrowY = toNode.y - 30 * Math.sin(angle); + + ctx.beginPath(); + ctx.moveTo(arrowX, arrowY); + ctx.lineTo( + arrowX - arrowLength * Math.cos(angle - arrowAngle), + arrowY - arrowLength * Math.sin(angle - arrowAngle) + ); + ctx.moveTo(arrowX, arrowY); + ctx.lineTo( + arrowX - arrowLength * Math.cos(angle + arrowAngle), + arrowY - arrowLength * Math.sin(angle + arrowAngle) + ); + ctx.stroke(); + } + + // Draw label + if (edge.label) { + const midX = (fromNode.x + toNode.x) / 2; + const midY = (fromNode.y + toNode.y) / 2; + ctx.fillStyle = '#6b7280'; + ctx.font = '10px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText(edge.label, midX, midY - 5); + } + } + }); + + // Draw nodes + nodes.forEach(node => { + const isSelected = selectedNode === node.id; + const isHovered = hoveredNode === node.id; + const radius = 25 + node.connections * 2; + + // Node shadow + if (isSelected || isHovered) { + ctx.shadowColor = 'rgba(0, 0, 0, 0.2)'; + ctx.shadowBlur = 10; + ctx.shadowOffsetX = 2; + ctx.shadowOffsetY = 2; + } + + // Node circle + ctx.beginPath(); + ctx.arc(node.x, node.y, radius, 0, 2 * Math.PI); + + if (isSelected) { + ctx.fillStyle = '#fbbf24'; // yellow for selected + } else if (isHovered) { + ctx.fillStyle = '#fde68a'; // light yellow for hovered + } else if (node.type === 'soroban') { + ctx.fillStyle = '#10b981'; // green for soroban + } else { + ctx.fillStyle = '#8b5cf6'; // purple for rust + } + + ctx.fill(); + ctx.shadowColor = 'transparent'; + + // Node border + ctx.strokeStyle = isSelected ? '#1f2937' : '#e5e7eb'; + ctx.lineWidth = isSelected ? 3 : 2; + ctx.stroke(); + + // Node label + ctx.fillStyle = '#1f2937'; + ctx.font = isSelected ? 'bold 12px sans-serif' : '11px sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(node.name, node.x, node.y); + + // Connection count badge + if (node.connections > 0) { + ctx.beginPath(); + ctx.arc(node.x + radius - 5, node.y - radius + 5, 10, 0, 2 * Math.PI); + ctx.fillStyle = '#ef4444'; + ctx.fill(); + ctx.fillStyle = 'white'; + ctx.font = 'bold 10px sans-serif'; + ctx.fillText(node.connections.toString(), node.x + radius - 5, node.y - radius + 5); + } + }); + }; + + const handleCanvasClick = (event: React.MouseEvent) => { + const canvas = canvasRef.current; + if (!canvas) return; + + const rect = canvas.getBoundingClientRect(); + const x = event.clientX - rect.left; + const y = event.clientY - rect.top; + + // Find clicked node + const clickedNode = nodes.find(node => { + const distance = Math.sqrt((x - node.x) ** 2 + (y - node.y) ** 2); + return distance <= 30 + node.connections * 2; + }); + + setSelectedNode(clickedNode ? clickedNode.id : null); + }; + + const handleCanvasMouseMove = (event: React.MouseEvent) => { + const canvas = canvasRef.current; + if (!canvas) return; + + const rect = canvas.getBoundingClientRect(); + const x = event.clientX - rect.left; + const y = event.clientY - rect.top; + + // Find hovered node + const hoveredNodeFound = nodes.find(node => { + const distance = Math.sqrt((x - node.x) ** 2 + (y - node.y) ** 2); + return distance <= 30 + node.connections * 2; + }); + + setHoveredNode(hoveredNodeFound ? hoveredNodeFound.id : null); + canvas.style.cursor = hoveredNodeFound ? 'pointer' : 'default'; + }; + + if (isLoading) { + return ( +
+
Analyzing contracts...
+
+ ); + } + + if (!interactionGraph) { + return ( +
+
No contract data available
+
+ ); + } + + const selectedContract = selectedNode ? interactionGraph.contracts.find(c => c.id === selectedNode) : null; + + return ( +
+
+

Enhanced Contract Interaction Visualizer

+

+ Interactive visualization of contract interactions with internal vs external call analysis. +

+
+ + {/* Stats Cards */} + {stats && ( +
+
+
Total Contracts
+
{stats.totalContracts}
+
+
+
Total Interactions
+
{stats.totalInteractions}
+
+
+
Internal Calls
+
{stats.internalCalls}
+
+
+
External Calls
+
{stats.externalCalls}
+
+
+ )} + + {/* Controls */} +
+
+ + + +
+ +
+ + + +
+
+ + {/* Main Visualization */} +
+
+
+ +
+ + {/* Legend */} +
+
+
+ Soroban Contract +
+
+
+ Rust Module +
+
+
+ Internal Call +
+
+
+ External Call +
+
+
+ + {/* Contract Details Panel */} +
+
+

Contract Details

+ + {selectedContract ? ( +
+
+

{selectedContract.name}

+

Type: {selectedContract.type}

+

Functions: {selectedContract.functions.length}

+

File: {selectedContract.filePath}

+
+ +
+
Functions:
+ {selectedContract.functions.map(func => ( +
+
+ {func.name} + + {func.visibility} + +
+ {func.calls.length > 0 && ( +
+ Calls: {func.calls.length} ({func.calls.filter(c => c.type === 'internal').length} internal, {func.calls.filter(c => c.type === 'external').length} external) +
+ )} +
+ ))} +
+
+ ) : ( +

Click on a contract node to view details

+ )} +
+
+
+
+ ); +}; + +export default EnhancedContractVisualizer; diff --git a/frontend/app/components/README_ContractVisualization.md b/frontend/app/components/README_ContractVisualization.md new file mode 100644 index 0000000..4ca19b6 --- /dev/null +++ b/frontend/app/components/README_ContractVisualization.md @@ -0,0 +1,151 @@ +# Contract Interaction Visualization + +This feature provides an interactive visualization of how different contracts in a project interact with each other, highlighting the distinction between internal and external calls. + +## Features + +### 🎯 Core Functionality +- **Interactive Graph Visualization**: Displays contracts as nodes and their interactions as edges +- **Internal vs External Call Distinction**: Uses different colors and styles to distinguish between internal contract calls and external contract calls +- **Multiple Layout Algorithms**: Supports circular, force-directed, and hierarchical layouts +- **Real-time Filtering**: Filter to show all calls, only internal calls, or only external calls +- **Contract Details Panel**: Click on any contract node to view detailed information about its functions and call patterns + +### 🔍 Analysis Capabilities +- **Contract Parsing**: Automatically parses Rust/Soroban contract files to extract function definitions and call patterns +- **Cross-Contract Call Detection**: Identifies calls between different contracts +- **Function-Level Analysis**: Provides detailed information about each function's visibility and call patterns +- **Interaction Statistics**: Shows overall statistics about contract interactions + +### 🎨 Visual Design +- **Color Coding**: + - Green nodes: Soroban contracts + - Purple nodes: Rust modules + - Blue edges: Internal calls + - Red edges: External calls +- **Edge Weight**: Line thickness represents call frequency +- **Interactive Elements**: Hover effects, selection states, and smooth transitions + +## Components + +### `EnhancedContractVisualizer` +The main visualization component that provides: +- Canvas-based rendering for performance +- Interactive controls for filtering and layout +- Real-time statistics display +- Contract details panel + +### `ContractAnalyzer` +The analysis engine that: +- Parses contract source code +- Extracts function definitions and call patterns +- Builds interaction graphs +- Provides statistics and insights + +## Usage + +### Accessing the Visualization +1. Navigate to the main dashboard +2. Click "Contract Visualization" in the navigation header +3. The visualization will load with sample contract data + +### Interacting with the Visualization +- **Click on nodes**: Select a contract to view detailed information +- **Use filters**: Toggle between all/internal/external calls +- **Change layouts**: Switch between circular, force-directed, and hierarchical layouts +- **Hover**: See tooltips and interactive feedback + +### Understanding the Display +- **Node size**: Represents the number of connections (larger = more connected) +- **Edge thickness**: Represents call frequency (thicker = more frequent calls) +- **Connection badges**: Small numbers on nodes show total connection count +- **Edge arrows**: Indicate direction of calls + +## Technical Implementation + +### Architecture +- **Frontend**: React with TypeScript +- **Visualization**: HTML5 Canvas for performance +- **Analysis**: Custom parser for Rust/Soroban contracts +- **State Management**: React hooks for local state + +### Data Flow +1. Contract files are parsed by the `ContractAnalyzer` +2. Interaction graphs are built from parsed data +3. Visualization components render the graph +4. User interactions update the display state + +### Supported Contract Types +- **Soroban Contracts**: Smart contracts for the Stellar network +- **Rust Modules**: General Rust code modules +- **Cross-Contract Patterns**: Calls between different contracts + +## File Structure + +``` +frontend/app/ +├── components/ +│ ├── EnhancedContractVisualizer.tsx # Main visualization component +│ ├── ContractInteractionVisualizer.tsx # Basic visualization (legacy) +│ └── README_ContractVisualization.md # This documentation +├── lib/ +│ └── contractAnalyzer.ts # Analysis engine +└── visualization/ + └── page.tsx # Visualization page +``` + +## Development Notes + +### Extending the Analyzer +To add support for new contract patterns: +1. Update the `ContractAnalyzer` class +2. Add new parsing patterns in `extractFunctionCalls` +3. Update the visualization types as needed + +### Adding New Layouts +To implement additional layout algorithms: +1. Add the layout case in `applyLayout` +2. Implement the positioning logic +3. Update the layout selector UI + +### Performance Considerations +- Canvas rendering is used for better performance with large graphs +- Layout calculations are optimized with efficient algorithms +- State updates are batched to minimize re-renders + +## Future Enhancements + +### Planned Features +- **Real-time Updates**: Live monitoring of contract interactions +- **Export Capabilities**: Save visualizations as images or data +- **Advanced Filtering**: Filter by function name, contract type, or call patterns +- **Code Integration**: Direct integration with contract source code +- **Performance Metrics**: Visual representation of gas costs and execution times + +### Integration Opportunities +- **Security Analysis**: Combine with existing security scanning features +- **Testing Integration**: Visualize test coverage and interaction patterns +- **Documentation Generation**: Auto-generate interaction diagrams for documentation + +## Troubleshooting + +### Common Issues +- **No data displayed**: Ensure contract files are properly formatted and accessible +- **Performance issues**: Large contract sets may require pagination or filtering +- **Layout problems**: Some layouts may not work well with very small or very large graphs + +### Debug Information +- Check browser console for parsing errors +- Verify contract file paths and accessibility +- Ensure proper TypeScript types are maintained + +## Contributing + +When contributing to the visualization feature: +1. Maintain TypeScript type safety +2. Follow existing code patterns and naming conventions +3. Add appropriate error handling +4. Update documentation for new features +5. Test with various contract patterns and sizes + +This visualization feature provides a powerful tool for understanding contract interactions and identifying potential security issues or optimization opportunities in complex contract systems. diff --git a/frontend/app/dashboard/page.tsx b/frontend/app/dashboard/page.tsx index 77ebbfa..39543f8 100644 --- a/frontend/app/dashboard/page.tsx +++ b/frontend/app/dashboard/page.tsx @@ -150,6 +150,12 @@ export default function DashboardPage() { Security Dashboard
+ + Contract Visualization + = new Map(); + private interactionGraph: InteractionGraph | null = null; + + /** + * Parse contract files to extract interaction data + */ + async analyzeContracts(contractFiles: { path: string; content: string }[]): Promise { + // Parse each contract file + for (const file of contractFiles) { + const contract = await this.parseContractFile(file.path, file.content); + if (contract) { + this.contracts.set(contract.id, contract); + } + } + + // Build interaction graph + this.interactionGraph = this.buildInteractionGraph(); + + return this.interactionGraph; + } + + /** + * Parse a single contract file + */ + private async parseContractFile(filePath: string, content: string): Promise { + try { + const lines = content.split('\n'); + const fileName = filePath.split('/').pop() || ''; + const contractId = fileName.replace(/\.(rs|sol)$/, ''); + + // Determine contract type + const isSoroban = content.includes('soroban_sdk') || + content.includes('#[contract]') || + content.includes('contractimpl'); + + const contract: ParsedContract = { + id: contractId, + name: this.toPascalCase(contractId), + filePath, + type: isSoroban ? 'soroban' : 'rust', + functions: [], + imports: this.extractImports(content), + structs: this.extractStructs(content) + }; + + // Extract functions + contract.functions = this.extractFunctions(content, contractId); + + return contract; + } catch (error) { + console.error(`Error parsing contract file ${filePath}:`, error); + return undefined; + } + } + + /** + * Extract import statements + */ + private extractImports(content: string): string[] { + const imports: string[] = []; + const importRegex = /use\s+([^;]+);/g; + let match; + + while ((match = importRegex.exec(content)) !== null) { + imports.push(match[1].trim()); + } + + return imports; + } + + /** + * Extract struct definitions + */ + private extractStructs(content: string): ParsedStruct[] { + const structs: ParsedStruct[] = []; + const structRegex = /pub\s+struct\s+(\w+)\s*\{([^}]+)\}/g; + let match; + + while ((match = structRegex.exec(content)) !== null) { + const structName = match[1]; + const structBody = match[2]; + const fields = this.extractStructFields(structBody); + + structs.push({ + name: structName, + fields, + line: this.getLineNumber(content, match.index) + }); + } + + return structs; + } + + /** + * Extract fields from struct body + */ + private extractStructFields(structBody: string): StructField[] { + const fields: StructField[] = []; + const fieldLines = structBody.split(',').map(line => line.trim()); + + for (const line of fieldLines) { + if (line) { + const parts = line.split(':').map(part => part.trim()); + if (parts.length === 2) { + fields.push({ + name: parts[0], + type: parts[1] + }); + } + } + } + + return fields; + } + + /** + * Extract function definitions and their calls + */ + private extractFunctions(content: string, contractId: string): ParsedFunction[] { + const functions: ParsedFunction[] = []; + + // Match function definitions including Soroban contract functions + const functionRegex = /(?:pub\s+)?(?:async\s+)?fn\s+(\w+)\s*\(([^)]*)\)(?:\s*->\s*([^{]+))?\s*\{/g; + let match; + + while ((match = functionRegex.exec(content)) !== null) { + const functionName = match[1]; + const parameters = this.parseParameters(match[2]); + const returnType = match[3]?.trim(); + const isPublic = content.substring(Math.max(0, match.index - 10), match.index).includes('pub'); + const isAsync = match[0].includes('async'); + const line = this.getLineNumber(content, match.index); + + // Extract function body to analyze calls + const functionBody = this.extractFunctionBody(content, match.index); + const calls = this.extractFunctionCalls(functionBody, contractId); + + functions.push({ + name: functionName, + visibility: isPublic ? 'public' : 'private', + isAsync, + parameters, + returnType, + calls, + line + }); + } + + return functions; + } + + /** + * Parse function parameters + */ + private parseParameters(paramString: string): Parameter[] { + if (!paramString.trim()) return []; + + const parameters: Parameter[] = []; + const paramParts = paramString.split(',').map(part => part.trim()); + + for (const part of paramParts) { + const colonIndex = part.lastIndexOf(':'); + if (colonIndex > 0) { + const name = part.substring(0, colonIndex).trim(); + const type = part.substring(colonIndex + 1).trim(); + parameters.push({ name, type }); + } + } + + return parameters; + } + + /** + * Extract function body content + */ + private extractFunctionBody(content: string, startIndex: number): string { + const afterBrace = content.indexOf('{', startIndex); + if (afterBrace === -1) return ''; + + let braceCount = 1; + let endIndex = afterBrace + 1; + + while (endIndex < content.length && braceCount > 0) { + if (content[endIndex] === '{') braceCount++; + else if (content[endIndex] === '}') braceCount--; + endIndex++; + } + + return content.substring(afterBrace + 1, endIndex - 1); + } + + /** + * Extract function calls from function body + */ + private extractFunctionCalls(functionBody: string, currentContractId: string): FunctionCall[] { + const calls: FunctionCall[] = []; + + // Pattern for function calls: function_name(...) or module::function_name(...) + const callRegex = /(?:(\w+)::)?(\w+)\s*\(/g; + let match; + + while ((match = callRegex.exec(functionBody)) !== null) { + const modulePath = match[1]; + const functionName = match[2]; + const line = this.getLineNumber(functionBody, match.index); + + // Skip certain common function calls + if (this.isCommonUtilityFunction(functionName)) { + continue; + } + + // Determine if this is an external contract call + let targetContract: string | undefined; + let callType: 'internal' | 'external' = 'internal'; + let isContractCall = false; + + if (modulePath) { + // Check if module path corresponds to another contract + const targetContractId = this.findContractByModule(modulePath); + if (targetContractId && targetContractId !== currentContractId) { + targetContract = targetContractId; + callType = 'external'; + isContractCall = true; + } + } else { + // Local function call - check if it's in the same contract + const isLocalFunction = this.isLocalFunction(functionName, currentContractId); + if (!isLocalFunction) { + // Might be a contract call through env.invoke_contract or similar + isContractCall = this.isContractCallPattern(functionBody, match.index); + if (isContractCall) { + callType = 'external'; + } + } + } + + calls.push({ + targetContract, + functionName, + type: callType, + line, + isContractCall + }); + } + + // Also check for Soroban contract invocation patterns + const sorobanCalls = this.extractSorobanContractCalls(functionBody, currentContractId); + calls.push(...sorobanCalls); + + return calls; + } + + /** + * Extract Soroban-specific contract calls + */ + private extractSorobanContractCalls(functionBody: string, currentContractId: string): FunctionCall[] { + const calls: FunctionCall[] = []; + + // Pattern for env.invoke_contract or similar + const sorobanCallRegex = /env\.invoke_contract\s*\(\s*&(\w+)/g; + let match; + + while ((match = sorobanCallRegex.exec(functionBody)) !== null) { + const addressVar = match[1]; + const line = this.getLineNumber(functionBody, match.index); + + // Try to resolve which contract this address refers to + const targetContract = this.resolveContractFromAddress(addressVar); + + calls.push({ + targetContract, + functionName: 'invoke_contract', + type: targetContract && targetContract !== currentContractId ? 'external' : 'internal', + line, + isContractCall: true + }); + } + + return calls; + } + + /** + * Check if function is a common utility function + */ + private isCommonUtilityFunction(functionName: string): boolean { + const commonFunctions = [ + 'println', 'format', 'to_string', 'to_vec', 'clone', 'unwrap', 'expect', + 'ok_or', 'map', 'filter', 'fold', 'collect', 'into_iter', 'iter', + 'len', 'is_empty', 'push', 'pop', 'insert', 'remove', 'get', 'set', + 'new', 'default', 'from', 'into', 'try_from', 'try_into', + 'add', 'sub', 'mul', 'div', 'rem', 'checked_add', 'checked_sub', + 'checked_mul', 'checked_div', 'saturating_add', 'saturating_sub', + 'wrapping_add', 'wrapping_sub', 'wrapping_mul', 'wrapping_div' + ]; + + return commonFunctions.includes(functionName); + } + + /** + * Find contract ID by module path + */ + private findContractByModule(modulePath: string): string | undefined { + // Simple heuristic: check if module path matches any contract ID + for (const [contractId, contract] of this.contracts) { + if (modulePath.includes(contractId) || contractId.includes(modulePath)) { + return contractId; + } + } + return undefined; + } + + /** + * Check if function is local to the current contract + */ + private isLocalFunction(functionName: string, contractId: string): boolean { + const contract = this.contracts.get(contractId); + if (!contract) return false; + + return contract.functions.some(func => func.name === functionName); + } + + /** + * Check if the pattern indicates a contract call + */ + private isContractCallPattern(functionBody: string, index: number): boolean { + const context = functionBody.substring(Math.max(0, index - 100), index + 100); + return context.includes('invoke_contract') || + context.includes('call') || + context.includes('Address'); + } + + /** + * Resolve contract from address variable (simplified) + */ + private resolveContractFromAddress(addressVar: string): string | undefined { + // This is a simplified implementation + // In practice, you'd need to track variable assignments and constants + return undefined; + } + + /** + * Build interaction graph from parsed contracts + */ + private buildInteractionGraph(): InteractionGraph { + const interactions: ContractInteraction[] = []; + const contracts = Array.from(this.contracts.values()); + + for (const contract of contracts) { + for (const func of contract.functions) { + for (const call of func.calls) { + if (call.isContractCall || call.targetContract) { + const targetContract = call.targetContract || this.inferTargetContract(call.functionName); + + if (targetContract) { + interactions.push({ + fromContract: contract.id, + fromFunction: func.name, + toContract: targetContract, + toFunction: call.functionName, + type: call.type, + frequency: 1, // Could be enhanced with actual call frequency data + callChain: [contract.id, targetContract] + }); + } + } + } + } + } + + return { + contracts, + interactions + }; + } + + /** + * Infer target contract from function name + */ + private inferTargetContract(functionName: string): string | undefined { + // Simple heuristic: check if function name suggests a contract + for (const [contractId, contract] of this.contracts) { + if (contract.functions.some(func => func.name === functionName)) { + return contractId; + } + } + return undefined; + } + + /** + * Get line number for a given index in content + */ + private getLineNumber(content: string, index: number): number { + const before = content.substring(0, index); + return before.split('\n').length; + } + + /** + * Convert string to PascalCase + */ + private toPascalCase(str: string): string { + return str.replace(/(?:^|[-_])(\w)/g, (_, char) => char.toUpperCase()); + } + + /** + * Get interaction statistics + */ + getInteractionStats(): { + totalContracts: number; + totalInteractions: number; + internalCalls: number; + externalCalls: number; + mostConnectedContract: string | null; + } { + if (!this.interactionGraph) { + return { + totalContracts: 0, + totalInteractions: 0, + internalCalls: 0, + externalCalls: 0, + mostConnectedContract: null + }; + } + + const connectionCounts = new Map(); + let internalCalls = 0; + let externalCalls = 0; + + for (const interaction of this.interactionGraph.interactions) { + connectionCounts.set( + interaction.fromContract, + (connectionCounts.get(interaction.fromContract) || 0) + 1 + ); + + if (interaction.type === 'internal') { + internalCalls++; + } else { + externalCalls++; + } + } + + let mostConnectedContract: string | undefined; + let maxConnections = 0; + + for (const [contract, count] of connectionCounts) { + if (count > maxConnections) { + maxConnections = count; + mostConnectedContract = contract; + } + } + + return { + totalContracts: this.interactionGraph.contracts.length, + totalInteractions: this.interactionGraph.interactions.length, + internalCalls, + externalCalls, + mostConnectedContract + }; + } +} diff --git a/frontend/app/visualization/page.tsx b/frontend/app/visualization/page.tsx new file mode 100644 index 0000000..8874c68 --- /dev/null +++ b/frontend/app/visualization/page.tsx @@ -0,0 +1,22 @@ +'use client'; + +import EnhancedContractVisualizer from '../components/EnhancedContractVisualizer'; + +export default function VisualizationPage() { + return ( +
+
+
+

+ Contract Interaction Visualization +

+

+ Analyze and visualize how different contracts in your project interact with each other. +

+
+ + +
+
+ ); +}