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
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import {
maskNonCode,
createLineResolver,
extractFunctions,
receiverBefore,
resolveTokenBindings,
resolveTokenFromReceiver,
blockStackAt,
isInLoop,
} from '../common/source-utils';

export type LoopBoundType = 'bounded_range' | 'collection_iterator' | 'unbounded' | 'dynamic_condition';

export interface LoopContext {
loopType: 'for' | 'while' | 'loop';
boundType: LoopBoundType;
boundExpression: string;
line: number;
}

export interface CrossContractCallInLoopSite {
fn: string;
targetContract: string;
method: string;
line: number;
offset: number;
loopContext: LoopContext;
severity: 'critical' | 'high' | 'medium';
estimatedCostMultiplier: number;
message: string;
suggestion: string;
}

export interface CrossContractCallsInLoopReport {
callsInLoops: CrossContractCallInLoopSite[];
totalCallsInLoops: number;
affectedFunctions: string[];
recommendations: string[];
}

const CROSS_CONTRACT_CALL_PATTERNS = [
/env\.invoke_contract\s*\(/,
/Client::new\s*\(/,
/ContractClient\s*::\s*new\s*\(/,
/invoke_contract_check_auth\s*\(/,
/\.\s*(transfer|transfer_from|balance|approve|mint|burn|clawback|allowance)\s*\(/,
];

/**
* Classifies loop bounds and expressions from the source preceding a block.
*/
export function classifyLoopHeader(precedingSource: string): LoopContext {
const forMatch = precedingSource.match(/for\s+([A-Za-z0-9_(),\s]+)\s+in\s+([^{]+)/);
if (forMatch) {
const expr = forMatch[2].trim();
const isRange = /\d+\s*\.\.\s*=?\s*\d+/.test(expr);
return {
loopType: 'for',
boundType: isRange ? 'bounded_range' : 'collection_iterator',
boundExpression: expr,
line: 0,
};
}

const whileMatch = precedingSource.match(/while\s+([^{]+)/);
if (whileMatch) {
const expr = whileMatch[1].trim();
const isTrue = expr === 'true';
return {
loopType: 'while',
boundType: isTrue ? 'unbounded' : 'dynamic_condition',
boundExpression: expr,
line: 0,
};
}

return {
loopType: 'loop',
boundType: 'unbounded',
boundExpression: 'unbounded loop',
line: 0,
};
}

/**
* Detect cross-contract calls inside loop bodies and extract detailed loop context.
*/
export function detectCrossContractCallsInLoops(source: string): CrossContractCallInLoopSite[] {
const masked = maskNonCode(source);
const lineOf = createLineResolver(source);
const bindings = resolveTokenBindings(masked, source);
const functions = extractFunctions(masked, source);
const callSites: CrossContractCallInLoopSite[] = [];

for (const fn of functions) {
for (const pattern of CROSS_CONTRACT_CALL_PATTERNS) {
const re = new RegExp(pattern.source, 'g');
const body = masked.slice(fn.bodyStart, fn.bodyEnd);
let m: RegExpExecArray | null;

while ((m = re.exec(body)) !== null) {
const offset = fn.bodyStart + m.index;
const stack = blockStackAt(masked, fn.bodyStart, offset);

if (!isInLoop(stack)) {
continue;
}

// Find the nearest loop frame
const loopFrame = stack.slice().reverse().find((f) => f.kind === 'loop');
const loopHeaderSnippet = loopFrame
? source.slice(Math.max(fn.bodyStart, loopFrame.start - 80), loopFrame.start)
: '';

const loopContext = classifyLoopHeader(loopHeaderSnippet);
loopContext.line = loopFrame ? lineOf(loopFrame.start) : lineOf(offset);

// Resolve method & target contract
const receiver = receiverBefore(source, offset);
const targetContract = resolveTokenFromReceiver(receiver, bindings) || 'external_contract';
const lineText = source.slice(offset, Math.min(source.length, offset + 120));
const methodMatch = lineText.match(/(?:invoke_contract\s*\([^,]+,\s*&?Symbol::new\([^,]+,\s*"([^"]+)"|\.([A-Za-z0-9_]+)\s*\()/);
const method = methodMatch ? (methodMatch[1] || methodMatch[2]) : 'call';

const severity = loopContext.boundType === 'unbounded'
? 'critical'
: loopContext.boundType === 'collection_iterator'
? 'high'
: 'medium';

const costMultiplier = loopContext.boundType === 'bounded_range' ? 5 : 20;

callSites.push({
fn: fn.name,
targetContract,
method,
line: lineOf(offset),
offset,
loopContext,
severity,
estimatedCostMultiplier: costMultiplier,
message: `Cross-contract call to '${targetContract}.${method}()' inside a '${loopContext.loopType}' loop (${loopContext.boundType}) in function '${fn.name}'.`,
suggestion:
`Repeated cross-contract invocations inside loops multiply auth, VM dispatch, and storage costs by ~${costMultiplier}x. ` +
`Batch external calls into a single multicall invocation or hoist loop-invariant queries outside the loop.`,
});
}
}
}

// Deduplicate call sites by line & method
const seen = new Set<string>();
const uniqueSites: CrossContractCallInLoopSite[] = [];
for (const site of callSites.sort((a, b) => a.line - b.line)) {
const key = `${site.line}|${site.method}|${site.targetContract}`;
if (!seen.has(key)) {
seen.add(key);
uniqueSites.push(site);
}
}

return uniqueSites;
}

/**
* Full analysis entry point.
*/
export function analyzeCrossContractCallsInLoops(source: string): CrossContractCallsInLoopReport {
const callsInLoops = detectCrossContractCallsInLoops(source);
const affectedFunctions = Array.from(new Set(callsInLoops.map((c) => c.fn)));

const recommendations: string[] = [];
if (callsInLoops.length > 0) {
recommendations.push(
`Found ${callsInLoops.length} cross-contract call(s) executed inside loops across ${affectedFunctions.length} function(s).`,
);
recommendations.push(
'Use batch settlement or multicall interfaces on target contracts to process multiple operations in a single invocation.',
);
}

return {
callsInLoops,
totalCallsInLoops: callsInLoops.length,
affectedFunctions,
recommendations,
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* Rule: soroban-cross-contract-calls-in-loop (#876)
* Detects cross-contract calls executed repeatedly inside loops.
*/
import {
detectCrossContractCallsInLoops,
CrossContractCallInLoopSite,
} from '../../../../analyzers/soroban/calls/cross-contract-calls-in-loop-analyzer';

export interface CrossContractCallInLoopFinding {
ruleId: 'soroban-cross-contract-calls-in-loop';
line: number;
message: string;
suggestion: string;
severity: 'critical' | 'high' | 'medium';
targetContract: string;
method: string;
boundType: string;
}

export function detectCrossContractCallsInsideLoops(source: string): CrossContractCallInLoopFinding[] {
const sites = detectCrossContractCallsInLoops(source);
return sites.map((s: CrossContractCallInLoopSite) => ({
ruleId: 'soroban-cross-contract-calls-in-loop' as const,
line: s.line,
message: s.message,
suggestion: s.suggestion,
severity: s.severity,
targetContract: s.targetContract,
method: s.method,
boundType: s.loopContext.boundType,
}));
}
1 change: 1 addition & 0 deletions packages/rules/soroban/src/calls/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@
export * from './nested-calls-rule';
export * from './cross-contract-calls-rule';
export * from './redundant-calls-rule';
export * from './cross-contract-calls-in-loop-rule';
63 changes: 63 additions & 0 deletions packages/rules/soroban/tests/cross-contract-calls-in-loop.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { detectCrossContractCallsInsideLoops } from '../src/calls/cross-contract-calls-in-loop-rule';
import { analyzeCrossContractCallsInLoops } from '../../../analyzers/soroban/calls/cross-contract-calls-in-loop-analyzer';

describe('Detect Cross-Contract Calls Inside Soroban Loops (#876)', () => {
const CONTRACT_WITH_LOOP_CALLS = `
pub fn batch_distribute(env: Env, token: Address, recipients: Vec<Address>, amounts: Vec<i128>) {
let client = token::Client::new(&env, &token);
for i in 0..recipients.len() {
let r = recipients.get(i).unwrap();
let a = amounts.get(i).unwrap();
client.transfer(&env.current_contract_address(), &r, &a);
}
}

pub fn poll_oracles(env: Env, oracles: Vec<Address>) {
for oracle in oracles.iter() {
env.invoke_contract(&oracle, &Symbol::new(&env, "get_price"), Vec::new(&env));
}
}

pub fn single_call(env: Env, token: Address, to: Address, amount: i128) {
let client = token::Client::new(&env, &token);
client.transfer(&env.current_contract_address(), &to, &amount);
}
`;

test('detects token transfer calls inside for-loops', () => {
const findings = detectCrossContractCallsInsideLoops(CONTRACT_WITH_LOOP_CALLS);

expect(findings.length).toBe(2);
expect(findings.some((f) => f.method === 'transfer')).toBe(true);
expect(findings.some((f) => f.targetContract === 'token')).toBe(true);
});

test('detects invoke_contract calls inside collection iterations', () => {
const findings = detectCrossContractCallsInsideLoops(CONTRACT_WITH_LOOP_CALLS);

const oracleFinding = findings.find((f) => f.method === 'get_price' || f.method === 'invoke_contract');
expect(oracleFinding).toBeDefined();
expect(oracleFinding?.boundType).toBe('collection_iterator');
expect(oracleFinding?.severity).toBe('high');
});

test('ignores contract calls outside loops', () => {
const report = analyzeCrossContractCallsInLoops(CONTRACT_WITH_LOOP_CALLS);

expect(report.affectedFunctions).toContain('batch_distribute');
expect(report.affectedFunctions).toContain('poll_oracles');
expect(report.affectedFunctions).not.toContain('single_call');
});

test('returns 0 findings on loop-free contracts', () => {
const clean = `
pub fn pay(env: Env, token: Address, to: Address, amount: i128) {
let client = token::Client::new(&env, &token);
client.transfer(&env.current_contract_address(), &to, &amount);
}
`;

const findings = detectCrossContractCallsInsideLoops(clean);
expect(findings.length).toBe(0);
});
});
Loading