diff --git a/packages/rules/soroban/src/analyzer/callgraph-analyzer.ts b/packages/rules/soroban/src/analyzer/callgraph-analyzer.ts new file mode 100644 index 0000000..20224e7 --- /dev/null +++ b/packages/rules/soroban/src/analyzer/callgraph-analyzer.ts @@ -0,0 +1,160 @@ +/** + * Soroban Call Graph Analyzer + * + * Builds a call graph from Soroban contract source and detects: + * - Nested (deep) contract call chains (#771) + * - Cross-contract call patterns (#772) + * - Redundant (repeated identical) calls (#773) + */ + +export interface CallNode { + /** The calling function name */ + caller: string; + /** The callee (contract.method or env.invoke_contract) */ + callee: string; + /** Line where the call appears */ + line: number; + /** Raw argument string (used for redundancy checks) */ + args: string; +} + +export interface CallGraphFinding { + rule: string; + line: number; + message: string; + suggestion: string; + severity: 'high' | 'medium' | 'low'; +} + +const CROSS_CONTRACT_PATTERNS = [ + /env\.invoke_contract\s*\(/, + /Client::new\s*\(/, + /ContractClient\s*::\s*new\s*\(/, + /invoke_contract_check_auth\s*\(/, +]; + +const DEPTH_THRESHOLD = 3; + +/** + * Extract all contract call sites from Soroban Rust source. + */ +function extractCallSites(source: string): CallNode[] { + const calls: CallNode[] = []; + const lines = source.split('\n'); + + // Track current function context + let currentFn = ''; + const fnPattern = /fn\s+([a-zA-Z0-9_]+)\s*\(/; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const fnMatch = line.match(fnPattern); + if (fnMatch) currentFn = fnMatch[1]; + + for (const pattern of CROSS_CONTRACT_PATTERNS) { + if (pattern.test(line)) { + // Extract callee name and args + const calleeMatch = line.match(/([A-Za-z0-9_:]+)\s*\(([^)]*)/); + const callee = calleeMatch ? calleeMatch[1] : 'unknown'; + const args = calleeMatch ? calleeMatch[2].trim() : ''; + calls.push({ caller: currentFn, callee, line: i + 1, args }); + } + } + } + + return calls; +} + +/** + * Detect deep call chains by counting cross-contract hops per function. + */ +function detectDeepCallChains(calls: CallNode[]): CallGraphFinding[] { + const findings: CallGraphFinding[] = []; + const callsPerFn = new Map(); + + for (const call of calls) { + const existing = callsPerFn.get(call.caller) ?? []; + existing.push(call); + callsPerFn.set(call.caller, existing); + } + + for (const [fn, fnCalls] of callsPerFn.entries()) { + if (fnCalls.length >= DEPTH_THRESHOLD) { + findings.push({ + rule: 'soroban-nested-calls', + line: fnCalls[0].line, + message: `Function '${fn}' makes ${fnCalls.length} cross-contract calls, forming a deep call chain.`, + suggestion: + 'Reduce cross-contract call depth by batching operations or restructuring contract responsibilities.', + severity: 'high', + }); + } + } + + return findings; +} + +/** + * Detect cross-contract calls (any use of invoke_contract / Client::new). + */ +function detectCrossContractCalls(calls: CallNode[]): CallGraphFinding[] { + return calls.map((call) => ({ + rule: 'soroban-cross-contract-call', + line: call.line, + message: `Cross-contract call to '${call.callee}' in function '${call.caller}'. Each call adds execution overhead.`, + suggestion: + 'Cache results locally when the same contract is called repeatedly with unchanged inputs.', + severity: 'medium' as const, + })); +} + +/** + * Detect redundant calls — identical callee + args called more than once. + */ +function detectRedundantCalls(calls: CallNode[]): CallGraphFinding[] { + const findings: CallGraphFinding[] = []; + const seen = new Map(); + + for (const call of calls) { + const key = `${call.caller}::${call.callee}(${call.args})`; + if (seen.has(key)) { + const first = seen.get(key)!; + findings.push({ + rule: 'soroban-redundant-call', + line: call.line, + message: `Redundant call to '${call.callee}(${call.args})' in '${call.caller}' — identical call already made at line ${first.line}.`, + suggestion: `Cache the result of '${call.callee}' in a local variable and reuse it instead of calling again.`, + severity: 'medium', + }); + } else { + seen.set(key, call); + } + } + + return findings; +} + +export interface CallGraphAnalysisResult { + calls: CallNode[]; + findings: CallGraphFinding[]; +} + +/** + * Analyze Soroban contract source for call graph issues. + * + * Covers: + * - #771 Nested expensive calls (depth >= DEPTH_THRESHOLD) + * - #772 Cross-contract call tracking + * - #773 Redundant identical calls + */ +export function analyzeCallGraph(source: string): CallGraphAnalysisResult { + const calls = extractCallSites(source); + + const findings: CallGraphFinding[] = [ + ...detectDeepCallChains(calls), + ...detectCrossContractCalls(calls), + ...detectRedundantCalls(calls), + ]; + + return { calls, findings }; +} diff --git a/packages/rules/soroban/src/analyzer/serialization-analyzer.ts b/packages/rules/soroban/src/analyzer/serialization-analyzer.ts new file mode 100644 index 0000000..25e1715 --- /dev/null +++ b/packages/rules/soroban/src/analyzer/serialization-analyzer.ts @@ -0,0 +1,178 @@ +/** + * Soroban Serialization Cost Analyzer (#774) + * + * Detects expensive serialization / deserialization patterns: + * - Repeated to_xdr / from_xdr calls on the same value + * - Large struct types passed by value through serialization boundaries + * - Unnecessary conversions (e.g., String → Bytes → String) + */ + +export interface SerializationFinding { + rule: string; + line: number; + message: string; + suggestion: string; + severity: 'high' | 'medium' | 'low'; +} + +/** Patterns that indicate a (de)serialization operation */ +const SERIALIZE_PATTERNS = [ + { regex: /\.to_xdr\s*\(/, label: 'to_xdr' }, + { regex: /\.from_xdr\s*\(/, label: 'from_xdr' }, + { regex: /ScVal::from\s*\(/, label: 'ScVal::from' }, + { regex: /ScVal::into\s*\(/, label: 'ScVal::into' }, + { regex: /Bytes::from_slice\s*\(/, label: 'Bytes::from_slice' }, + { regex: /String::from_slice\s*\(/, label: 'String::from_slice' }, + { regex: /\.serialize\s*\(/, label: 'serialize' }, + { regex: /\.deserialize\s*\(/, label: 'deserialize' }, +]; + +/** Heuristic: large struct literals with many fields */ +const LARGE_STRUCT_THRESHOLD = 5; +const STRUCT_FIELD_PATTERN = /\w+\s*:\s*\w+/g; + +function detectRepeatedSerializations(source: string): SerializationFinding[] { + const findings: SerializationFinding[] = []; + const lines = source.split('\n'); + + // Map: serialization-key → first line seen + const seen = new Map(); + + let currentFn = ''; + const fnPattern = /fn\s+([a-zA-Z0-9_]+)\s*\(/; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const fnMatch = line.match(fnPattern); + if (fnMatch) { + currentFn = fnMatch[1]; + seen.clear(); // reset per function + } + + for (const { regex, label } of SERIALIZE_PATTERNS) { + if (regex.test(line)) { + // Use the expression on the left as part of the key + const exprMatch = line.match(/([a-zA-Z0-9_]+)\s*\.\w+\s*\(/); + const expr = exprMatch ? exprMatch[1] : `line${i}`; + const key = `${currentFn}::${label}(${expr})`; + + if (seen.has(key)) { + findings.push({ + rule: 'soroban-repeated-serialization', + line: i + 1, + message: `Repeated '${label}' on '${expr}' in function '${currentFn}' (first seen at line ${seen.get(key)}).`, + suggestion: `Cache the serialized result of '${expr}' in a local variable to avoid redundant CPU and memory costs.`, + severity: 'medium', + }); + } else { + seen.set(key, i + 1); + } + } + } + } + + return findings; +} + +function detectUnnecessaryConversions(source: string): SerializationFinding[] { + const findings: SerializationFinding[] = []; + const lines = source.split('\n'); + + // Detect chained conversions: e.g., .to_string().as_bytes() or Bytes → String → Bytes + const chainPatterns = [ + { + pattern: /\.to_string\(\).*\.as_bytes\(\)/, + message: 'Unnecessary String→bytes conversion chain.', + suggestion: 'Work directly with Bytes instead of converting to String first.', + }, + { + pattern: /from_xdr.*to_xdr|to_xdr.*from_xdr/, + message: 'Serialize then immediately deserialize (or vice versa) is redundant.', + suggestion: 'Pass the original value directly instead of round-tripping through XDR.', + }, + { + pattern: /String::from_slice.*Bytes::from_slice|Bytes::from_slice.*String::from_slice/, + message: 'Redundant type conversion between String and Bytes.', + suggestion: 'Choose one representation and avoid converting back and forth.', + }, + ]; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + for (const { pattern, message, suggestion } of chainPatterns) { + if (pattern.test(line)) { + findings.push({ + rule: 'soroban-unnecessary-conversion', + line: i + 1, + message, + suggestion, + severity: 'low', + }); + } + } + } + + return findings; +} + +function detectLargeSerializedValues(source: string): SerializationFinding[] { + const findings: SerializationFinding[] = []; + const lines = source.split('\n'); + + let inStruct = false; + let structStart = 0; + let fieldCount = 0; + let structName = ''; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + // Detect struct literal being passed to a serialization call + if (!inStruct && SERIALIZE_PATTERNS.some(({ regex }) => regex.test(line))) { + const structMatch = line.match(/(\w+)\s*\{/); + if (structMatch) { + inStruct = true; + structStart = i + 1; + structName = structMatch[1]; + fieldCount = 0; + } + } + + if (inStruct) { + const fields = line.match(STRUCT_FIELD_PATTERN); + if (fields) fieldCount += fields.length; + if (line.includes('}')) { + if (fieldCount >= LARGE_STRUCT_THRESHOLD) { + findings.push({ + rule: 'soroban-large-serialized-value', + line: structStart, + message: `Large struct '${structName}' with ${fieldCount} fields is serialized. Large serialized values increase Soroban CPU and memory costs.`, + suggestion: + 'Consider splitting the struct, serializing only the fields that change, or using a more compact representation.', + severity: 'high', + }); + } + inStruct = false; + } + } + } + + return findings; +} + +export interface SerializationAnalysisResult { + findings: SerializationFinding[]; +} + +/** + * Analyze Soroban contract source for serialization cost issues (#774). + */ +export function analyzeSerializationCosts(source: string): SerializationAnalysisResult { + const findings: SerializationFinding[] = [ + ...detectRepeatedSerializations(source), + ...detectUnnecessaryConversions(source), + ...detectLargeSerializedValues(source), + ]; + + return { findings }; +} diff --git a/packages/rules/soroban/src/calls/cross-contract-calls-rule.ts b/packages/rules/soroban/src/calls/cross-contract-calls-rule.ts new file mode 100644 index 0000000..13e07e2 --- /dev/null +++ b/packages/rules/soroban/src/calls/cross-contract-calls-rule.ts @@ -0,0 +1,28 @@ +/** + * Rule: soroban-cross-contract-call (#772) + * Tracks and reports cross-contract call patterns. + */ +import { analyzeCallGraph, CallGraphFinding } from '../analyzer/callgraph-analyzer'; + +export interface CrossContractCallFinding { + ruleId: 'soroban-cross-contract-call'; + line: number; + message: string; + suggestion: string; + severity: 'high' | 'medium' | 'low'; +} + +export function detectCrossContractCalls(source: string): CrossContractCallFinding[] { + const { findings } = analyzeCallGraph(source); + return findings + .filter((f): f is CallGraphFinding & { rule: 'soroban-cross-contract-call' } => + f.rule === 'soroban-cross-contract-call', + ) + .map((f) => ({ + ruleId: 'soroban-cross-contract-call' as const, + line: f.line, + message: f.message, + suggestion: f.suggestion, + severity: f.severity, + })); +} diff --git a/packages/rules/soroban/src/calls/index.ts b/packages/rules/soroban/src/calls/index.ts new file mode 100644 index 0000000..39ec76b --- /dev/null +++ b/packages/rules/soroban/src/calls/index.ts @@ -0,0 +1,9 @@ +/** + * Soroban call-related rules (#771, #772, #773) + * + * Wraps the callgraph analyzer into named rule objects + * compatible with the GasGuard rule interface. + */ +export * from './nested-calls-rule'; +export * from './cross-contract-calls-rule'; +export * from './redundant-calls-rule'; diff --git a/packages/rules/soroban/src/calls/nested-calls-rule.ts b/packages/rules/soroban/src/calls/nested-calls-rule.ts new file mode 100644 index 0000000..074c6e6 --- /dev/null +++ b/packages/rules/soroban/src/calls/nested-calls-rule.ts @@ -0,0 +1,28 @@ +/** + * Rule: soroban-nested-calls (#771) + * Detects unnecessarily deep Soroban contract-call chains. + */ +import { analyzeCallGraph, CallGraphFinding } from '../analyzer/callgraph-analyzer'; + +export interface NestedCallsFinding { + ruleId: 'soroban-nested-calls'; + line: number; + message: string; + suggestion: string; + severity: 'high' | 'medium' | 'low'; +} + +export function detectNestedCalls(source: string): NestedCallsFinding[] { + const { findings } = analyzeCallGraph(source); + return findings + .filter((f): f is CallGraphFinding & { rule: 'soroban-nested-calls' } => + f.rule === 'soroban-nested-calls', + ) + .map((f) => ({ + ruleId: 'soroban-nested-calls' as const, + line: f.line, + message: f.message, + suggestion: f.suggestion, + severity: f.severity, + })); +} diff --git a/packages/rules/soroban/src/calls/redundant-calls-rule.ts b/packages/rules/soroban/src/calls/redundant-calls-rule.ts new file mode 100644 index 0000000..1fb4567 --- /dev/null +++ b/packages/rules/soroban/src/calls/redundant-calls-rule.ts @@ -0,0 +1,28 @@ +/** + * Rule: soroban-redundant-call (#773) + * Detects repeated calls to the same contract function with identical arguments. + */ +import { analyzeCallGraph, CallGraphFinding } from '../analyzer/callgraph-analyzer'; + +export interface RedundantCallFinding { + ruleId: 'soroban-redundant-call'; + line: number; + message: string; + suggestion: string; + severity: 'high' | 'medium' | 'low'; +} + +export function detectRedundantCalls(source: string): RedundantCallFinding[] { + const { findings } = analyzeCallGraph(source); + return findings + .filter((f): f is CallGraphFinding & { rule: 'soroban-redundant-call' } => + f.rule === 'soroban-redundant-call', + ) + .map((f) => ({ + ruleId: 'soroban-redundant-call' as const, + line: f.line, + message: f.message, + suggestion: f.suggestion, + severity: f.severity, + })); +} diff --git a/packages/rules/soroban/src/index.ts b/packages/rules/soroban/src/index.ts index 3050fb0..77044e6 100644 --- a/packages/rules/soroban/src/index.ts +++ b/packages/rules/soroban/src/index.ts @@ -1,5 +1,9 @@ export * from './storage-rent-check'; export * from './analyzer/wasm-inspector'; +export * from './analyzer/callgraph-analyzer'; +export * from './analyzer/serialization-analyzer'; +export * from './calls'; +export * from './serialization'; export * from './events'; export * from './authorization'; export * from './budget'; diff --git a/packages/rules/soroban/src/serialization/index.ts b/packages/rules/soroban/src/serialization/index.ts new file mode 100644 index 0000000..01a261f --- /dev/null +++ b/packages/rules/soroban/src/serialization/index.ts @@ -0,0 +1,4 @@ +/** + * Soroban serialization rules (#774) + */ +export * from './serialization-cost-rule'; diff --git a/packages/rules/soroban/src/serialization/serialization-cost-rule.ts b/packages/rules/soroban/src/serialization/serialization-cost-rule.ts new file mode 100644 index 0000000..efda015 --- /dev/null +++ b/packages/rules/soroban/src/serialization/serialization-cost-rule.ts @@ -0,0 +1,5 @@ +/** + * Rule: soroban-serialization-cost (#774) + * Wraps the serialization analyzer into a named rule. + */ +export { analyzeSerializationCosts, SerializationFinding, SerializationAnalysisResult } from '../analyzer/serialization-analyzer';