From 82b3298096facd0d0014f24bf51fdc2e72e81c50 Mon Sep 17 00:00:00 2001 From: adeniran19-maker <288629594+adeniran19-maker@users.noreply.github.com> Date: Sat, 29 Aug 2026 14:44:35 +0100 Subject: [PATCH] feat(soroban): detect cross-contract calls inside loops (#876) --- .../cross-contract-calls-in-loop-analyzer.ts | 188 ++++++++++++++++++ .../cross-contract-calls-in-loop-rule.ts | 33 +++ packages/rules/soroban/src/calls/index.ts | 1 + .../cross-contract-calls-in-loop.spec.ts | 63 ++++++ 4 files changed, 285 insertions(+) create mode 100644 packages/analyzers/soroban/calls/cross-contract-calls-in-loop-analyzer.ts create mode 100644 packages/rules/soroban/src/calls/cross-contract-calls-in-loop-rule.ts create mode 100644 packages/rules/soroban/tests/cross-contract-calls-in-loop.spec.ts diff --git a/packages/analyzers/soroban/calls/cross-contract-calls-in-loop-analyzer.ts b/packages/analyzers/soroban/calls/cross-contract-calls-in-loop-analyzer.ts new file mode 100644 index 00000000..58b9f0e2 --- /dev/null +++ b/packages/analyzers/soroban/calls/cross-contract-calls-in-loop-analyzer.ts @@ -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(); + 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, + }; +} diff --git a/packages/rules/soroban/src/calls/cross-contract-calls-in-loop-rule.ts b/packages/rules/soroban/src/calls/cross-contract-calls-in-loop-rule.ts new file mode 100644 index 00000000..dbc4d885 --- /dev/null +++ b/packages/rules/soroban/src/calls/cross-contract-calls-in-loop-rule.ts @@ -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, + })); +} diff --git a/packages/rules/soroban/src/calls/index.ts b/packages/rules/soroban/src/calls/index.ts index 39ec76bc..cceb3ccb 100644 --- a/packages/rules/soroban/src/calls/index.ts +++ b/packages/rules/soroban/src/calls/index.ts @@ -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'; diff --git a/packages/rules/soroban/tests/cross-contract-calls-in-loop.spec.ts b/packages/rules/soroban/tests/cross-contract-calls-in-loop.spec.ts new file mode 100644 index 00000000..fd2df4d5 --- /dev/null +++ b/packages/rules/soroban/tests/cross-contract-calls-in-loop.spec.ts @@ -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
, amounts: Vec) { + 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
) { + 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); + }); +});