diff --git a/packages/analyzers/soroban/ledger/__tests__/ledger-access-analyzer.spec.ts b/packages/analyzers/soroban/ledger/__tests__/ledger-access-analyzer.spec.ts new file mode 100644 index 00000000..45f17c4b --- /dev/null +++ b/packages/analyzers/soroban/ledger/__tests__/ledger-access-analyzer.spec.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from '@jest/globals'; +import { SorobanLedgerAccessAnalyzer } from '../ledger-access-analyzer'; + +describe('SorobanLedgerAccessAnalyzer', () => { + const analyzer = new SorobanLedgerAccessAnalyzer(); + + it('correctly tracks and classifies ledger reads and writes across storage tiers', () => { + const sampleContract = ` + pub fn deposit(env: Env, user: Address, amount: i128) { + let admin = Symbol::new(&env, "admin"); + let is_admin: bool = env.storage().instance().has(&admin); + let balance: i128 = env.storage().persistent().get(&user).unwrap_or(0); + + env.storage().persistent().set(&user, &(balance + amount)); + env.storage().temporary().set(&Symbol::new(&env, "nonce"), &1); + } + `; + + const result = analyzer.analyze(sampleContract, 'deposit_contract.rs'); + + expect(result.contractPath).toBe('deposit_contract.rs'); + expect(result.accesses.length).toBe(4); + + // Classifications + const instanceHas = result.accesses.find((a) => a.storageTier === 'instance'); + expect(instanceHas?.accessType).toBe('has'); + expect(instanceHas?.key).toBe('admin'); + + const persistentGet = result.accesses.find((a) => a.storageTier === 'persistent' && a.accessType === 'read'); + expect(persistentGet).toBeDefined(); + + const persistentSet = result.accesses.find((a) => a.storageTier === 'persistent' && a.accessType === 'write'); + expect(persistentSet).toBeDefined(); + + const tempSet = result.accesses.find((a) => a.storageTier === 'temporary'); + expect(tempSet?.key).toBe('nonce'); + + // Metrics + expect(result.metrics.totalReads).toBe(2); + expect(result.metrics.totalWrites).toBe(2); + expect(result.metrics.instanceAccesses).toBe(1); + expect(result.metrics.persistentAccesses).toBe(2); + expect(result.metrics.temporaryAccesses).toBe(1); + expect(result.metrics.uniqueKeysAccessed).toBe(3); + }); + + it('detects repeated reads to the same key and generates optimization findings', () => { + const sampleContract = ` + pub fn calculate_rewards(env: Env, user: Address) -> i128 { + let count = Symbol::new(&env, "count"); + let v1 = env.storage().persistent().get(&count).unwrap_or(0); + let v2 = env.storage().persistent().get(&count).unwrap_or(0); + let v3 = env.storage().persistent().get(&count).unwrap_or(0); + v1 + v2 + v3 + } + `; + + const result = analyzer.analyze(sampleContract); + + expect(result.repeatedAccesses.length).toBe(1); + expect(result.repeatedAccesses[0].key).toBe('count'); + expect(result.repeatedAccesses[0].redundantReadsCount).toBe(2); + expect(result.metrics.repeatedReads).toBe(2); + + const finding = result.findings.find((f) => f.ruleId === 'SOROBAN-LEDGER-01'); + expect(finding).toBeDefined(); + expect(finding?.message).toContain("Repeated ledger read detected for key 'count'"); + }); + + it('detects ledger accesses inside loops', () => { + const loopContract = ` + pub fn batch_process(env: Env, users: Vec
) { + for user in users.iter() { + let bal = env.storage().persistent().get(&user).unwrap_or(0); + env.storage().persistent().set(&user, &(bal + 10)); + } + } + `; + + const result = analyzer.analyze(loopContract); + + expect(result.metrics.loopAccesses).toBe(2); + const loopFindings = result.findings.filter((f) => f.ruleId === 'SOROBAN-LEDGER-03'); + expect(loopFindings.length).toBeGreaterThan(0); + expect(loopFindings[0].message).toContain('inside a loop structure'); + }); + + it('detects multiple writes to the same key in a single execution flow', () => { + const multiWriteContract = ` + pub fn update_totals(env: Env) { + let total_key = Symbol::new(&env, "total"); + env.storage().instance().set(&total_key, &100); + env.storage().instance().set(&total_key, &200); + } + `; + + const result = analyzer.analyze(multiWriteContract); + + expect(result.metrics.repeatedWrites).toBe(1); + const writeFinding = result.findings.find((f) => f.ruleId === 'SOROBAN-LEDGER-02'); + expect(writeFinding).toBeDefined(); + expect(writeFinding?.severity).toBe('high'); + }); +}); diff --git a/packages/analyzers/soroban/ledger/index.ts b/packages/analyzers/soroban/ledger/index.ts new file mode 100644 index 00000000..4aa0596f --- /dev/null +++ b/packages/analyzers/soroban/ledger/index.ts @@ -0,0 +1,2 @@ +export * from './types'; +export * from './ledger-access-analyzer'; diff --git a/packages/analyzers/soroban/ledger/ledger-access-analyzer.ts b/packages/analyzers/soroban/ledger/ledger-access-analyzer.ts new file mode 100644 index 00000000..792a3c77 --- /dev/null +++ b/packages/analyzers/soroban/ledger/ledger-access-analyzer.ts @@ -0,0 +1,311 @@ +import { + LedgerAccessEntry, + LedgerAccessFinding, + LedgerAccessMetrics, + LedgerAccessType, + LedgerAnalysisResult, + LedgerStorageTier, + RepeatedAccessGroup, +} from './types'; + +export class SorobanLedgerAccessAnalyzer { + private readonly logger = { + debug: (_msg: string) => {}, + warn: (_msg: string) => {}, + error: (_msg: string) => {}, + }; + + /** + * Analyzes Soroban contract source code for ledger access patterns, + * classifying reads/writes, detecting repeated accesses, and computing metrics. + */ + public analyze(sourceCode: string, contractPath: string = 'contract.rs'): LedgerAnalysisResult { + this.logger.debug(`Analyzing ledger access patterns for contract: ${contractPath}`); + + const accesses: LedgerAccessEntry[] = []; + const lines = sourceCode.split('\n'); + + let currentFunction: string | undefined; + let loopDepth = 0; + + // Track variable-to-key symbol definitions (e.g. let ADMIN = Symbol::new(&env, "admin")) + const symbolMap = new Map(); + for (const line of lines) { + const symMatch = line.match(/(?:let|const)\s+([a-zA-Z0-9_]+)\s*=\s*(?:Symbol::new|symbol_short!)\s*\(&?env,\s*"([^"]+)"\)/); + if (symMatch) { + symbolMap.set(symMatch[1], symMatch[2]); + } + } + + // Scan lines for function boundaries, loops, and ledger accesses + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const trimmed = line.trim(); + const lineNumber = i + 1; + + // Track function context + const fnMatch = line.match(/(?:pub\s+)?fn\s+([a-zA-Z0-9_]+)\s*\(/); + if (fnMatch) { + currentFunction = fnMatch[1]; + } + + // Track loop depth + if ( + line.includes('for ') || + line.includes('while ') || + line.includes('loop {') || + line.includes('.for_each(') || + line.includes('.iter()') + ) { + loopDepth++; + } + if (line.includes('}') && loopDepth > 0 && !line.includes('{')) { + loopDepth = Math.max(0, loopDepth - 1); + } + + // Detect storage tier calls + if (line.includes('env.storage()') || line.includes('.storage().')) { + let storageTier: LedgerStorageTier = 'persistent'; + if (line.includes('.instance()')) { + storageTier = 'instance'; + } else if (line.includes('.temporary()')) { + storageTier = 'temporary'; + } else if (line.includes('.persistent()')) { + storageTier = 'persistent'; + } + + // Classify access type + let accessType: LedgerAccessType | undefined; + if (line.includes('.get(') || line.includes('.get_unchecked(')) { + accessType = 'read'; + } else if (line.includes('.has(')) { + accessType = 'has'; + } else if (line.includes('.set(') || line.includes('.put(') || line.includes('.update(')) { + accessType = 'write'; + } else if (line.includes('.remove(') || line.includes('.delete(')) { + accessType = 'delete'; + } else if (line.includes('.extend_ttl(')) { + accessType = 'extend_ttl'; + } + + if (accessType) { + // Extract key name + let keyName = this.extractKeyName(line, symbolMap); + + accesses.push({ + id: `access-${accesses.length + 1}`, + key: keyName, + storageTier, + accessType, + line: lineNumber, + inLoop: loopDepth > 0, + functionName: currentFunction, + codeSnippet: trimmed, + }); + } + } + } + + // Group repeated accesses + const repeatedAccesses = this.groupRepeatedAccesses(accesses); + + // Compute metrics + const metrics = this.computeMetrics(accesses, repeatedAccesses); + + // Generate diagnostic findings + const findings = this.generateFindings(accesses, repeatedAccesses); + + return { + contractPath, + accesses, + repeatedAccesses, + findings, + metrics, + }; + } + + private extractKeyName(line: string, symbolMap: Map): string { + // Check for inline Symbol::new(&env, "key") + const symbolMatch = line.match(/(?:Symbol::new|symbol_short!)\s*\(&?env,\s*"([^"]+)"\)/); + if (symbolMatch && symbolMatch[1]) { + return symbolMatch[1]; + } + + // Check for DataKey enum variant: DataKey::User(addr), DataKey::Admin + const enumMatch = line.match(/&?DataKey::([a-zA-Z0-9_]+)(?:\(([^)]+)\))?/); + if (enumMatch) { + return enumMatch[2] ? `DataKey::${enumMatch[1]}(${enumMatch[2]})` : `DataKey::${enumMatch[1]}`; + } + + // Check for variable reference &var or var + const methodArgsMatch = line.match(/\.(?:get|has|set|remove|extend_ttl)\s*\(\s*&?([a-zA-Z0-9_]+)/); + if (methodArgsMatch && methodArgsMatch[1]) { + const varName = methodArgsMatch[1]; + return symbolMap.get(varName) ?? varName; + } + + return 'unknown_key'; + } + + private groupRepeatedAccesses(accesses: LedgerAccessEntry[]): RepeatedAccessGroup[] { + const keyMap = new Map(); + + for (const acc of accesses) { + if (!acc.key || acc.key === 'unknown_key') continue; + const groupKey = `${acc.storageTier}:${acc.key}`; + if (!keyMap.has(groupKey)) { + keyMap.set(groupKey, []); + } + keyMap.get(groupKey)!.push(acc); + } + + const groups: RepeatedAccessGroup[] = []; + + for (const [groupKey, entries] of keyMap.entries()) { + if (entries.length > 1) { + const [tier, ...keyParts] = groupKey.split(':'); + const key = keyParts.join(':'); + + let readsCount = 0; + let writesCount = 0; + let hasWriteAfterRead = false; + + for (const entry of entries) { + if (entry.accessType === 'read' || entry.accessType === 'has') { + readsCount++; + } else if (entry.accessType === 'write') { + writesCount++; + if (readsCount > 0) { + hasWriteAfterRead = true; + } + } + } + + groups.push({ + key, + storageTier: tier as LedgerStorageTier, + count: entries.length, + accesses: entries, + hasWriteAfterRead, + redundantReadsCount: Math.max(0, readsCount - 1), + }); + } + } + + return groups; + } + + private computeMetrics( + accesses: LedgerAccessEntry[], + repeatedGroups: RepeatedAccessGroup[], + ): LedgerAccessMetrics { + let totalReads = 0; + let totalWrites = 0; + let totalDeletes = 0; + let persistentAccesses = 0; + let instanceAccesses = 0; + let temporaryAccesses = 0; + let loopAccesses = 0; + + const uniqueKeys = new Set(); + + for (const acc of accesses) { + if (acc.accessType === 'read' || acc.accessType === 'has') { + totalReads++; + } else if (acc.accessType === 'write') { + totalWrites++; + } else if (acc.accessType === 'delete') { + totalDeletes++; + } + + if (acc.storageTier === 'persistent') persistentAccesses++; + else if (acc.storageTier === 'instance') instanceAccesses++; + else if (acc.storageTier === 'temporary') temporaryAccesses++; + + if (acc.inLoop) loopAccesses++; + if (acc.key) uniqueKeys.add(acc.key); + } + + let repeatedReads = 0; + let repeatedWrites = 0; + + for (const g of repeatedGroups) { + repeatedReads += g.redundantReadsCount; + const writes = g.accesses.filter((a) => a.accessType === 'write').length; + if (writes > 1) { + repeatedWrites += writes - 1; + } + } + + // Footprint estimation: base 64 bytes per unique key + 128 bytes per persistent entry + const estimatedFootprintBytes = uniqueKeys.size * 64 + persistentAccesses * 128; + + return { + totalReads, + totalWrites, + totalDeletes, + persistentAccesses, + instanceAccesses, + temporaryAccesses, + repeatedReads, + repeatedWrites, + loopAccesses, + uniqueKeysAccessed: uniqueKeys.size, + estimatedFootprintBytes, + }; + } + + private generateFindings( + accesses: LedgerAccessEntry[], + repeatedGroups: RepeatedAccessGroup[], + ): LedgerAccessFinding[] { + const findings: LedgerAccessFinding[] = []; + + // Finding 1: Repeated Reads of Same Key + for (const group of repeatedGroups) { + if (group.redundantReadsCount > 0) { + const firstRead = group.accesses.find((a) => a.accessType === 'read' || a.accessType === 'has'); + findings.push({ + ruleId: 'SOROBAN-LEDGER-01', + severity: 'medium', + line: firstRead?.line ?? 1, + key: group.key, + message: `Repeated ledger read detected for key '${group.key}' (${group.redundantReadsCount + 1} reads in same execution flow).`, + recommendation: `Cache the ledger entry in a local variable instead of querying env.storage().${group.storageTier}().get('${group.key}') multiple times.`, + estimatedSavings: `Saves ~${group.redundantReadsCount * 120} CPU & Read CPU budget instructions`, + }); + } + + // Finding 2: Repeated Writes of Same Key + const writes = group.accesses.filter((a) => a.accessType === 'write'); + if (writes.length > 1) { + findings.push({ + ruleId: 'SOROBAN-LEDGER-02', + severity: 'high', + line: writes[1].line, + key: group.key, + message: `Multiple ledger writes detected for key '${group.key}' (${writes.length} writes in single transaction).`, + recommendation: `Aggregate state mutations in memory and perform a single write at the end of the transaction.`, + estimatedSavings: `Saves ~${(writes.length - 1) * 500} Ledger Write fee units`, + }); + } + } + + // Finding 3: Ledger Access inside Loops + for (const acc of accesses) { + if (acc.inLoop) { + findings.push({ + ruleId: 'SOROBAN-LEDGER-03', + severity: acc.accessType === 'write' ? 'high' : 'medium', + line: acc.line, + key: acc.key, + message: `Ledger ${acc.accessType} operation executed inside a loop structure for key '${acc.key ?? 'entry'}'.`, + recommendation: `Extract ledger interactions outside the loop by batch reading or accumulating updates in a vector/map before writing once.`, + estimatedSavings: 'Significant reduction in CPU instruction count and ledger write fees', + }); + } + } + + return findings; + } +} diff --git a/packages/analyzers/soroban/ledger/types.ts b/packages/analyzers/soroban/ledger/types.ts new file mode 100644 index 00000000..e715e0c4 --- /dev/null +++ b/packages/analyzers/soroban/ledger/types.ts @@ -0,0 +1,56 @@ +export type LedgerAccessType = 'read' | 'write' | 'has' | 'delete' | 'extend_ttl'; + +export type LedgerStorageTier = 'persistent' | 'instance' | 'temporary'; + +export interface LedgerAccessEntry { + id: string; + key?: string; + storageTier: LedgerStorageTier; + accessType: LedgerAccessType; + line: number; + column?: number; + inLoop: boolean; + functionName?: string; + codeSnippet?: string; +} + +export interface RepeatedAccessGroup { + key: string; + storageTier: LedgerStorageTier; + count: number; + accesses: LedgerAccessEntry[]; + hasWriteAfterRead: boolean; + redundantReadsCount: number; +} + +export interface LedgerAccessMetrics { + totalReads: number; + totalWrites: number; + totalDeletes: number; + persistentAccesses: number; + instanceAccesses: number; + temporaryAccesses: number; + repeatedReads: number; + repeatedWrites: number; + loopAccesses: number; + uniqueKeysAccessed: number; + estimatedFootprintBytes: number; +} + +export interface LedgerAccessFinding { + ruleId: string; + severity: 'high' | 'medium' | 'low'; + message: string; + line: number; + key?: string; + recommendation: string; + estimatedSavings?: string; +} + +export interface LedgerAnalysisResult { + contractPath: string; + accesses: LedgerAccessEntry[]; + repeatedAccesses: RepeatedAccessGroup[]; + findings: LedgerAccessFinding[]; + metrics: LedgerAccessMetrics; +} diff --git a/packages/rules/soroban/ledger/index.ts b/packages/rules/soroban/ledger/index.ts index 85ddffcb..1017281d 100644 --- a/packages/rules/soroban/ledger/index.ts +++ b/packages/rules/soroban/ledger/index.ts @@ -1 +1,2 @@ +export * from './ledger-access-rule'; export * from './unused-ledger-reads-rule'; diff --git a/packages/rules/soroban/ledger/ledger-access-rule.ts b/packages/rules/soroban/ledger/ledger-access-rule.ts new file mode 100644 index 00000000..36235eb3 --- /dev/null +++ b/packages/rules/soroban/ledger/ledger-access-rule.ts @@ -0,0 +1,44 @@ +import { + LedgerAccessFinding, + LedgerAnalysisResult, + SorobanLedgerAccessAnalyzer, +} from '../../../analyzers/soroban/ledger'; + +export interface LedgerRuleWarning { + line: number; + column?: number; + ruleId: string; + severity: 'high' | 'medium' | 'low'; + key?: string; + message: string; + suggestion: string; + estimatedSavings?: string; +} + +export class SorobanLedgerAccessRule { + public static readonly RULE_ID = 'soroban-ledger-access'; + + private readonly analyzer: SorobanLedgerAccessAnalyzer; + + constructor() { + this.analyzer = new SorobanLedgerAccessAnalyzer(); + } + + public analyze(sourceCode: string, contractPath: string = 'contract.rs'): LedgerRuleWarning[] { + const analysis: LedgerAnalysisResult = this.analyzer.analyze(sourceCode, contractPath); + + return analysis.findings.map((f: LedgerAccessFinding) => ({ + line: f.line, + ruleId: f.ruleId, + severity: f.severity, + key: f.key, + message: f.message, + suggestion: f.recommendation, + estimatedSavings: f.estimatedSavings, + })); + } + + public getFullAnalysis(sourceCode: string, contractPath: string = 'contract.rs'): LedgerAnalysisResult { + return this.analyzer.analyze(sourceCode, contractPath); + } +} diff --git a/packages/rules/soroban/src/index.ts b/packages/rules/soroban/src/index.ts index b57c81fe..475946f5 100644 --- a/packages/rules/soroban/src/index.ts +++ b/packages/rules/soroban/src/index.ts @@ -17,3 +17,4 @@ export * from './deployment'; export * from './wasm'; export * from './storage'; export * from './suggestions'; +export * from '../ledger'; diff --git a/packages/rules/soroban/tests/ledger-access-rule.spec.ts b/packages/rules/soroban/tests/ledger-access-rule.spec.ts new file mode 100644 index 00000000..d37a160a --- /dev/null +++ b/packages/rules/soroban/tests/ledger-access-rule.spec.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from '@jest/globals'; +import { SorobanLedgerAccessRule } from '../ledger/ledger-access-rule'; + +describe('SorobanLedgerAccessRule', () => { + const rule = new SorobanLedgerAccessRule(); + + it('flags repeated reads and writes as diagnostic rule warnings', () => { + const contractCode = ` + pub fn execute_swap(env: Env, token: Address) { + let key = Symbol::new(&env, "reserves"); + let r1: i128 = env.storage().instance().get(&key).unwrap(); + let r2: i128 = env.storage().instance().get(&key).unwrap(); + + env.storage().instance().set(&key, &(r1 + 100)); + env.storage().instance().set(&key, &(r2 + 200)); + } + `; + + const warnings = rule.analyze(contractCode, 'swap.rs'); + expect(warnings.length).toBeGreaterThanOrEqual(2); + + const readWarning = warnings.find((w) => w.ruleId === 'SOROBAN-LEDGER-01'); + expect(readWarning).toBeDefined(); + expect(readWarning?.key).toBe('reserves'); + expect(readWarning?.suggestion).toContain('Cache the ledger entry in a local variable'); + + const writeWarning = warnings.find((w) => w.ruleId === 'SOROBAN-LEDGER-02'); + expect(writeWarning).toBeDefined(); + expect(writeWarning?.severity).toBe('high'); + }); + + it('provides complete analysis result with metrics via getFullAnalysis', () => { + const code = ` + pub fn init(env: Env, admin: Address) { + env.storage().instance().set(&Symbol::new(&env, "admin"), &admin); + } + `; + + const fullResult = rule.getFullAnalysis(code); + expect(fullResult.metrics.totalWrites).toBe(1); + expect(fullResult.metrics.instanceAccesses).toBe(1); + expect(fullResult.findings.length).toBe(0); + }); +});