Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 160 additions & 0 deletions packages/rules/soroban/src/analyzer/callgraph-analyzer.ts
Original file line number Diff line number Diff line change
@@ -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 = '<unknown>';
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<string, CallNode[]>();

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<string, CallNode>();

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 };
}
178 changes: 178 additions & 0 deletions packages/rules/soroban/src/analyzer/serialization-analyzer.ts
Original file line number Diff line number Diff line change
@@ -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<string, number>();

let currentFn = '<unknown>';
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 };
}
28 changes: 28 additions & 0 deletions packages/rules/soroban/src/calls/cross-contract-calls-rule.ts
Original file line number Diff line number Diff line change
@@ -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,
}));
}
9 changes: 9 additions & 0 deletions packages/rules/soroban/src/calls/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Loading
Loading