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);
+ });
+});