diff --git a/packages/analyzers/soroban/ttl/__tests__/ttl-analyzer.spec.ts b/packages/analyzers/soroban/ttl/__tests__/ttl-analyzer.spec.ts new file mode 100644 index 0000000..9a6a5c2 --- /dev/null +++ b/packages/analyzers/soroban/ttl/__tests__/ttl-analyzer.spec.ts @@ -0,0 +1,271 @@ +import { + SorobanTtlAnalyzer, + analyzeShortTtlValues, + analyzeTtl, + detectTtlOperations, + describeLedgers, + parseLedgerLiteral, + SOROBAN_ABSOLUTE_MIN_TTL_LEDGERS, + SOROBAN_RECOMMENDED_MIN_TTL_LEDGERS, +} from '../ttl-analyzer'; + +const CONTRACT = ` +pub fn write_config(env: Env, value: u32) { + env.storage().persistent().set(&DataKey::Config, &value); +} + +pub fn write_session(env: Env, session: Symbol) { + env.storage().persistent().set(&session, &1u32); +} + +pub fn cache_temp(env: Env, user: Address) { + env.storage().temporary().set(&user, &1u32); +} + +pub fn healthy_bump(env: Env) { + env.storage().persistent().set(&DataKey::Good, &1u32); + env.storage().persistent().extend_ttl(&DataKey::Good, 17_280, 518_400); +} + +pub fn short_bump(env: Env) { + env.storage().persistent().set(&DataKey::Short, &1u32); + env.storage().persistent().extend_ttl(&DataKey::Short, 1_000, 10_000); +} + +pub fn medium_bump(env: Env) { + env.storage().persistent().set(&DataKey::Medium, &1u32); + env.storage().persistent().extend_ttl(&DataKey::Medium, 17_280, 400_000); +} + +pub fn inverted(env: Env) { + env.storage().persistent().set(&DataKey::Inverted, &1u32); + env.storage().persistent().extend_ttl(&DataKey::Inverted, 500_000, 400_000); +} +`; + +describe('Soroban TTL analyzer (#885)', () => { + describe('parseLedgerLiteral', () => { + it('parses underscored literals', () => { + expect(parseLedgerLiteral('518_400')).toBe(518_400); + }); + + it('parses numeric type suffixes', () => { + expect(parseLedgerLiteral('17_280u32')).toBe(17_280); + expect(parseLedgerLiteral('100i64')).toBe(100); + }); + + it('evaluates simple products of literals', () => { + expect(parseLedgerLiteral('30 * 24 * 12')).toBe(8_640); + }); + + it('returns null for named constants it cannot evaluate', () => { + expect(parseLedgerLiteral('MAX_TTL')).toBeNull(); + expect(parseLedgerLiteral('')).toBeNull(); + }); + }); + + describe('describeLedgers', () => { + it('describes day-scale and minute-scale spans', () => { + expect(describeLedgers(SOROBAN_ABSOLUTE_MIN_TTL_LEDGERS)).toContain('days'); + expect(describeLedgers(12)).toContain('minutes'); + }); + }); + + describe('detectTtlOperations', () => { + it('detects writes and extensions with their storage tier', () => { + const operations = detectTtlOperations(CONTRACT); + + const writes = operations.filter((op) => op.kind === 'write'); + const extensions = operations.filter((op) => op.kind === 'extend_ttl'); + + expect(writes).toHaveLength(7); + expect(extensions).toHaveLength(4); + + const tempWrite = writes.find((op) => op.key === 'user'); + expect(tempWrite?.tier).toBe('temporary'); + + const persistentWrite = writes.find((op) => op.key === 'DataKey::Config'); + expect(persistentWrite?.tier).toBe('persistent'); + expect(persistentWrite?.functionName).toBe('write_config'); + }); + + it('captures the threshold and extend_to arguments of an extension', () => { + const extension = detectTtlOperations(CONTRACT).find( + (op) => op.kind === 'extend_ttl' && op.key === 'DataKey::Good', + ); + + expect(extension).toBeDefined(); + expect(extension?.thresholdArg).toBe('17_280'); + expect(extension?.extendToArg).toBe('518_400'); + expect(extension?.inLoop).toBe(false); + }); + + it('flags extensions performed inside loops', () => { + const operations = detectTtlOperations(` +pub fn looped(env: Env, users: Vec
) { + for user in users.iter() { + env.storage().persistent().extend_ttl(&user, 17_280, 518_400); + } +} +`); + const loopedExtension = operations.find((op) => op.kind === 'extend_ttl'); + expect(loopedExtension?.inLoop).toBe(true); + }); + }); + + describe('analyzeShortTtlValues', () => { + it('does not flag a well-sized extension', () => { + const findings = analyzeShortTtlValues( + `pub fn ok(env: Env) { + env.storage().persistent().extend_ttl(&Key::A, 17_280, 518_400); +}`, + ); + expect(findings).toHaveLength(0); + }); + + it('flags an extend_to below the one-day floor as high severity', () => { + const findings = analyzeShortTtlValues( + `pub fn short(env: Env) { + env.storage().persistent().extend_ttl(&Key::A, 1_000, 10_000); +}`, + ); + const finding = findings.find((f) => f.kind === 'short_extend_ttl'); + expect(finding).toBeDefined(); + expect(finding?.severity).toBe('high'); + expect(finding?.key).toBe('Key::A'); + expect(finding?.message).toContain('10000'); + expect(finding?.recommendation).toContain(String(SOROBAN_RECOMMENDED_MIN_TTL_LEDGERS)); + }); + + it('flags an extend_to below the 30-day recommendation as medium severity', () => { + const findings = analyzeShortTtlValues( + `pub fn medium(env: Env) { + env.storage().persistent().extend_ttl(&Key::A, 17_280, 400_000); +}`, + ); + const finding = findings.find((f) => f.kind === 'short_extend_ttl'); + expect(finding?.severity).toBe('medium'); + }); + + it('flags threshold >= extend_to as an ineffective range', () => { + const findings = analyzeShortTtlValues( + `pub fn inverted(env: Env) { + env.storage().persistent().extend_ttl(&Key::A, 500_000, 400_000); +}`, + ); + const finding = findings.find((f) => f.kind === 'invalid_extension_range'); + expect(finding).toBeDefined(); + expect(finding?.severity).toBe('high'); + expect(finding?.message).toMatch(/never raises/i); + }); + + it('flags a missing extend_to argument', () => { + const findings = analyzeShortTtlValues( + `pub fn malformed(env: Env) { + env.storage().persistent().extend_ttl(&Key::A, 17_280); +}`, + ); + const finding = findings.find((f) => f.kind === 'invalid_extension_range'); + expect(finding).toBeDefined(); + expect(finding?.message).toMatch(/omits the extend_to/i); + }); + + it('skips values it cannot evaluate (named constants)', () => { + const findings = analyzeShortTtlValues( + `pub fn consts(env: Env) { + env.storage().persistent().extend_ttl(&Key::A, MIN_THRESHOLD, MAX_TTL); +}`, + ); + expect(findings).toHaveLength(0); + }); + + it('honours custom thresholds', () => { + const findings = analyzeShortTtlValues( + `pub fn custom(env: Env) { + env.storage().persistent().extend_ttl(&Key::A, 1, 500); +}`, + { recommendedMinLedgers: 1_000, absoluteMinLedgers: 100 }, + ); + expect(findings).toHaveLength(1); + expect(findings[0].severity).toBe('medium'); + }); + }); + + describe('SorobanTtlAnalyzer', () => { + it('produces an aggregate report across all finding kinds', () => { + const result = new SorobanTtlAnalyzer().analyze(CONTRACT); + + const kinds = new Set(result.findings.map((f) => f.kind)); + expect(kinds.has('missing_extension')).toBe(true); + expect(kinds.has('short_extend_ttl')).toBe(true); + expect(kinds.has('invalid_extension_range')).toBe(true); + + // Two persistent writes without extension, three short TTLs and one + // ineffective range. + expect(result.findings).toHaveLength(6); + }); + + it('reports missing extensions for persistent entries only', () => { + const result = new SorobanTtlAnalyzer().analyze(CONTRACT); + const missingKeys = result.findings + .filter((f) => f.kind === 'missing_extension') + .map((f) => f.key); + + expect(missingKeys).toContain('DataKey::Config'); + expect(missingKeys).toContain('session'); + expect(missingKeys).not.toContain('user'); + }); + + it('fills metrics from the combined analysis', () => { + const { metrics } = new SorobanTtlAnalyzer().analyze(CONTRACT); + + expect(metrics.totalOperations).toBe(11); + expect(metrics.writes).toBe(7); + expect(metrics.extensions).toBe(4); + expect(metrics.persistentEntries).toBe(6); + expect(metrics.extendedEntries).toBe(4); + expect(metrics.unextendedEntries).toBe(2); + expect(metrics.shortTtlValues).toBe(3); + expect(metrics.invalidRanges).toBe(1); + }); + + it('counts extensions performed inside loops', () => { + const { metrics } = new SorobanTtlAnalyzer().analyze(` +pub fn looped(env: Env, users: Vec
) { + for user in users.iter() { + env.storage().persistent().extend_ttl(&user, 17_280, 518_400); + } +} +`); + expect(metrics.extensionsInLoops).toBe(1); + }); + + it('is clean and reassuring for a well-formed contract', () => { + const result = new SorobanTtlAnalyzer().analyze(` +pub fn healthy(env: Env) { + env.storage().persistent().set(&DataKey::X, &1u32); + env.storage().persistent().extend_ttl(&DataKey::X, 17_280, 518_400); +} +`); + expect(result.findings).toHaveLength(0); + expect(result.summary).toMatch(/No TTL risks detected/i); + }); + + it('does not treat comments or strings as real calls', () => { + const result = new SorobanTtlAnalyzer().analyze(` +// env.storage().persistent().extend_ttl(&Key::Fake, 1, 2); +pub fn real(env: Env) { + let s = "env.storage().persistent().set(&Key::Fake, &1u32)"; + env.storage().persistent().set(&DataKey::Real, &1u32); + env.storage().persistent().extend_ttl(&DataKey::Real, 17_280, 518_400); +} +`); + expect(result.findings).toHaveLength(0); + expect(result.metrics.persistentEntries).toBe(1); + }); + + it('is exposed through the analyzeTtl wrapper', () => { + expect(analyzeTtl(CONTRACT).findings).toHaveLength(6); + }); + }); +}); diff --git a/packages/analyzers/soroban/ttl/index.ts b/packages/analyzers/soroban/ttl/index.ts new file mode 100644 index 0000000..45932ae --- /dev/null +++ b/packages/analyzers/soroban/ttl/index.ts @@ -0,0 +1,2 @@ +export * from './types'; +export * from './ttl-analyzer'; diff --git a/packages/analyzers/soroban/ttl/ttl-analyzer.ts b/packages/analyzers/soroban/ttl/ttl-analyzer.ts new file mode 100644 index 0000000..32caa9e --- /dev/null +++ b/packages/analyzers/soroban/ttl/ttl-analyzer.ts @@ -0,0 +1,315 @@ +/** + * Soroban TTL Analyzer (issue #885) + * + * Lexical analysis of TTL configuration and expiration patterns in Soroban + * (Rust) contracts. The analyzer: + * + * 1. Detects TTL-related storage operations (`set` / `extend_ttl`) and the + * storage tier they target (`persistent` / `instance` / `temporary`). + * 2. Identifies persistent entries that are written but never TTL-extended, + * reusing the storage-level analyzer so both code paths agree (#886). + * 3. Detects unusually short `extend_to` values, and extension calls whose + * `threshold`/`extend_to` pair can never actually extend anything. + * 4. Emits actionable findings with a file line, key, message and concrete + * remediation. + * + * The analyzer is lexical rather than AST-based (matching the rest of the + * Soroban analyzers) and reuses the shared masking/offset helpers so comments + * and string literals cannot produce false positives. + */ + +import { + maskNonCode, + createLineResolver, + extractFunctions, + extractArgs, + splitArgs, + receiverBefore, + blockStackAt, + isInLoop, +} from '../common/source-utils'; +import { + analyzeMissingTtlExtensions, + analyzeExcessiveTtlExtensions, +} from '../storage/ttl-analyzer'; +import type { + ShortTtlOptions, + SorobanTtlAnalysisResult, + TtlAnalysisMetrics, + TtlFinding, + TtlOperation, + TtlStorageTier, +} from './types'; + +/** + * Soroban closes a ledger roughly every 5 seconds, so one day is 86_400 / 5 + * ledgers. These constants make the "unusually short" thresholds explicit and + * easy to keep in one place. + */ +export const SOROBAN_LEDGER_CLOSE_SECONDS = 5; +export const SOROBAN_LEDGERS_PER_DAY = 17_280; +/** Recommended minimum TTL (30 days) — close to the network maximum. */ +export const SOROBAN_RECOMMENDED_MIN_TTL_LEDGERS = 30 * SOROBAN_LEDGERS_PER_DAY; +/** Hard floor: anything below one day is flagged as critical. */ +export const SOROBAN_ABSOLUTE_MIN_TTL_LEDGERS = SOROBAN_LEDGERS_PER_DAY; + +const SHORT_TTL_RULE_ID = 'soroban-short-ttl'; + +function tierFromReceiver(receiver: string): TtlStorageTier | null { + if (/persistent\s*\(\s*\)/.test(receiver)) return 'persistent'; + if (/temporary\s*\(\s*\)/.test(receiver)) return 'temporary'; + if (/instance\s*\(\s*\)/.test(receiver)) return 'instance'; + return null; +} + +/** + * Resolve an `extend_ttl` argument to a ledger count when it is a literal. + * Named constants and non-trivial expressions return `null` — the analyzer + * refuses to guess a value it cannot evaluate. + */ +export function parseLedgerLiteral(arg: string): number | null { + const compact = arg.replace(/[_\s]+/g, ''); + if (compact.length === 0) return null; + + const literal = compact.match(/^(\d+)(?:u(?:8|16|32|64|128)|i(?:8|16|32|64|128))?$/); + if (literal) return Number(literal[1]); + + // Simple products of literals, e.g. `30 * 24 * 12`. + const factors = compact.split('*'); + if (factors.length > 1 && factors.length <= 4 && factors.every((f) => /^\d+$/.test(f))) { + return factors.reduce((acc, f) => acc * Number(f), 1); + } + + return null; +} + +/** Human-readable description of a ledger span (approximate). */ +export function describeLedgers(ledgers: number): string { + const seconds = ledgers * SOROBAN_LEDGER_CLOSE_SECONDS; + const days = seconds / 86_400; + if (days >= 1) return `${Math.round(days * 10) / 10} days`; + const hours = seconds / 3_600; + if (hours >= 1) return `${Math.round(hours * 10) / 10} hours`; + return `${Math.round((seconds / 60) * 10) / 10} minutes`; +} + +/** Detect every TTL-related storage operation in the source. */ +export function detectTtlOperations(source: string): TtlOperation[] { + const masked = maskNonCode(source); + const lineOf = createLineResolver(source); + const functions = extractFunctions(masked, source); + const operations: TtlOperation[] = []; + + const callRe = /\.(set|extend_ttl)\s*\(/g; + let match: RegExpExecArray | null; + while ((match = callRe.exec(masked)) !== null) { + const isExtend = match[1] === 'extend_ttl'; + const tier = tierFromReceiver(receiverBefore(masked, match.index)); + if (!tier) continue; + + const openParen = match.index + match[0].length - 1; + const args = splitArgs(extractArgs(masked, source, openParen).text); + if (args.length === 0) continue; + + const fn = functions.find((f) => f.bodyStart <= openParen && openParen <= f.bodyEnd); + const stack = fn ? blockStackAt(masked, fn.bodyStart, openParen) : []; + + const operation: TtlOperation = { + kind: isExtend ? 'extend_ttl' : 'write', + key: args[0], + tier, + line: lineOf(match.index), + functionName: fn?.name ?? '', + inLoop: isInLoop(stack), + }; + + if (isExtend) { + operation.thresholdArg = args[1]; + operation.extendToArg = args[2]; + } + + operations.push(operation); + } + + return operations; +} + +/** + * Flag `extend_ttl` calls whose configuration is unusually short or cannot + * extend anything: + * + * - `extend_to` below the absolute floor (default 1 day) — critical, + * - `extend_to` below the recommended minimum (default 30 days) — warning, + * - `threshold >= extend_to`, or a missing `extend_to` — ineffective call. + */ +export function analyzeShortTtlValues( + source: string, + options: ShortTtlOptions = {}, +): TtlFinding[] { + const recommendedMin = options.recommendedMinLedgers ?? SOROBAN_RECOMMENDED_MIN_TTL_LEDGERS; + const absoluteMin = options.absoluteMinLedgers ?? SOROBAN_ABSOLUTE_MIN_TTL_LEDGERS; + + const findings: TtlFinding[] = []; + + for (const operation of detectTtlOperations(source)) { + if (operation.kind !== 'extend_ttl') continue; + + const { key, functionName, line } = operation; + const threshold = + operation.thresholdArg !== undefined ? parseLedgerLiteral(operation.thresholdArg) : null; + const extendTo = + operation.extendToArg !== undefined ? parseLedgerLiteral(operation.extendToArg) : null; + + if (operation.extendToArg === undefined) { + findings.push({ + ruleId: SHORT_TTL_RULE_ID, + kind: 'invalid_extension_range', + severity: 'high', + line, + key, + functionName, + message: `extend_ttl on '${key}' omits the extend_to argument, so the entry TTL is never actually extended.`, + recommendation: + `Call \`env.storage().persistent().extend_ttl(&${key}, THRESHOLD, EXTEND_TO)\` with ` + + `EXTEND_TO >= ${recommendedMin} ledgers (~30 days).`, + }); + } else if (extendTo !== null && extendTo < absoluteMin) { + findings.push({ + ruleId: SHORT_TTL_RULE_ID, + kind: 'short_extend_ttl', + severity: 'high', + line, + key, + functionName, + message: + `extend_ttl on '${key}' extends the TTL to only ${extendTo} ledgers ` + + `(~${describeLedgers(extendTo)}), below the one-day floor of ${absoluteMin} ledgers. ` + + `The entry can expire unexpectedly and become unavailable.`, + recommendation: + `Raise the extend_to value for '${key}' to at least ${recommendedMin} ledgers ` + + `(~30 days), or move short-lived data to \`env.storage().temporary()\`.`, + }); + } else if (extendTo !== null && extendTo < recommendedMin) { + findings.push({ + ruleId: SHORT_TTL_RULE_ID, + kind: 'short_extend_ttl', + severity: 'medium', + line, + key, + functionName, + message: + `extend_ttl on '${key}' extends the TTL to ${extendTo} ledgers ` + + `(~${describeLedgers(extendTo)}), below the recommended ${recommendedMin} ledgers (~30 days).`, + recommendation: + `Extend '${key}' to at least ${recommendedMin} ledgers (~30 days), or document why a ` + + `shorter TTL is intentional and add a maintenance entrypoint that keeps it fresh.`, + }); + } + + if (threshold !== null && extendTo !== null && threshold >= extendTo) { + findings.push({ + ruleId: SHORT_TTL_RULE_ID, + kind: 'invalid_extension_range', + severity: 'high', + line, + key, + functionName, + message: + `extend_ttl on '${key}' uses threshold ${threshold} >= extend_to ${extendTo}; the ` + + `extension never raises the remaining TTL and effectively does nothing.`, + recommendation: + `Set threshold below extend_to for '${key}' (for example threshold ${Math.floor( + extendTo / 2, + )} with extend_to ${extendTo}).`, + }); + } else if (threshold !== null && threshold <= 0) { + findings.push({ + ruleId: SHORT_TTL_RULE_ID, + kind: 'invalid_extension_range', + severity: 'high', + line, + key, + functionName, + message: `extend_ttl on '${key}' uses a non-positive threshold of ${threshold} ledgers.`, + recommendation: + `Use a positive threshold for '${key}' so the extension triggers before the entry expires.`, + }); + } + } + + return findings; +} + +/** + * Soroban TTL analyzer — combines operation detection, missing-extension + * detection (#886), excessive-extension detection (#887) and short/invalid + * TTL value detection (#885) into a single actionable report. + */ +export class SorobanTtlAnalyzer { + public static readonly RULE_ID = 'soroban-ttl-analyzer'; + + public analyze(source: string, options: ShortTtlOptions = {}): SorobanTtlAnalysisResult { + const operations = detectTtlOperations(source); + const missing = analyzeMissingTtlExtensions(source); + const excessive = analyzeExcessiveTtlExtensions(source); + const shortTtl = analyzeShortTtlValues(source, options); + + const findings: TtlFinding[] = [ + ...missing.findings.map( + (finding): TtlFinding => ({ + ruleId: 'soroban-missing-ttl-extension', + kind: 'missing_extension', + severity: finding.severity, + line: finding.line, + key: finding.key, + functionName: finding.functionName, + message: finding.message, + recommendation: finding.suggestion, + }), + ), + ...excessive.findings.map( + (finding): TtlFinding => ({ + ruleId: 'soroban-excessive-ttl-extension', + kind: 'excessive_extension', + severity: finding.severity, + line: finding.line, + key: finding.key, + functionName: finding.functionName, + message: finding.message, + recommendation: finding.suggestion, + }), + ), + ...shortTtl, + ]; + + const writes = operations.filter((op) => op.kind === 'write').length; + const extensions = operations.filter((op) => op.kind === 'extend_ttl').length; + + const metrics: TtlAnalysisMetrics = { + totalOperations: operations.length, + writes, + extensions, + persistentEntries: missing.metrics.persistentEntries, + extendedEntries: missing.metrics.extendedEntries, + unextendedEntries: missing.metrics.unextendedEntries, + shortTtlValues: shortTtl.filter((f) => f.kind === 'short_extend_ttl').length, + invalidRanges: shortTtl.filter((f) => f.kind === 'invalid_extension_range').length, + extensionsInLoops: excessive.metrics.extensionsInLoops, + }; + + const summary = + findings.length === 0 + ? `No TTL risks detected across ${operations.length} storage operation(s).` + : `Found ${findings.length} TTL issue(s) across ${operations.length} storage operation(s).`; + + return { operations, findings, metrics, summary }; + } +} + +/** Convenience wrapper around {@link SorobanTtlAnalyzer}. */ +export function analyzeTtl( + source: string, + options: ShortTtlOptions = {}, +): SorobanTtlAnalysisResult { + return new SorobanTtlAnalyzer().analyze(source, options); +} diff --git a/packages/analyzers/soroban/ttl/types.ts b/packages/analyzers/soroban/ttl/types.ts new file mode 100644 index 0000000..e903125 --- /dev/null +++ b/packages/analyzers/soroban/ttl/types.ts @@ -0,0 +1,80 @@ +/** + * Soroban TTL analyzer types (issue #885). + * + * These types describe the storage/TTL operations found in a contract and the + * actionable findings produced from them. They are intentionally free of any + * framework dependency so the analyzer can be reused by the rule layer. + */ + +/** Storage tier an operation targets. */ +export type TtlStorageTier = 'persistent' | 'instance' | 'temporary'; + +/** Kind of storage operation the analyzer recognizes. */ +export type TtlOperationKind = 'write' | 'extend_ttl'; + +/** A single `set(...)` or `extend_ttl(...)` call site. */ +export interface TtlOperation { + kind: TtlOperationKind; + /** Normalized storage key expression (e.g. `DataKey::Config`). */ + key: string; + tier: TtlStorageTier; + /** 1-based line of the call site. */ + line: number; + functionName: string; + /** Raw threshold argument of an `extend_ttl` call. */ + thresholdArg?: string; + /** Raw `extend_to` argument of an `extend_ttl` call. */ + extendToArg?: string; + /** True when the call sits inside a loop body. */ + inLoop: boolean; +} + +/** Classification of a produced finding. */ +export type TtlFindingKind = + | 'missing_extension' + | 'excessive_extension' + | 'short_extend_ttl' + | 'invalid_extension_range'; + +/** An actionable TTL finding. */ +export interface TtlFinding { + ruleId: string; + kind: TtlFindingKind; + severity: 'high' | 'medium' | 'low'; + /** 1-based line of the offending call site. */ + line: number; + key?: string; + functionName?: string; + message: string; + /** Concrete remediation the developer can apply. */ + recommendation: string; +} + +/** Aggregate counters for a single analysis run. */ +export interface TtlAnalysisMetrics { + totalOperations: number; + writes: number; + extensions: number; + persistentEntries: number; + extendedEntries: number; + unextendedEntries: number; + shortTtlValues: number; + invalidRanges: number; + extensionsInLoops: number; +} + +/** Full analyzer output. */ +export interface SorobanTtlAnalysisResult { + operations: TtlOperation[]; + findings: TtlFinding[]; + metrics: TtlAnalysisMetrics; + summary: string; +} + +/** Tuning for "unusually short" TTL detection. */ +export interface ShortTtlOptions { + /** Ledgers below which an `extend_to` value is flagged (default 30 days). */ + recommendedMinLedgers?: number; + /** Ledgers below which an `extend_to` value is critical (default 1 day). */ + absoluteMinLedgers?: number; +} diff --git a/packages/rules/soroban/ttl/__tests__/ttl-rule.spec.ts b/packages/rules/soroban/ttl/__tests__/ttl-rule.spec.ts new file mode 100644 index 0000000..3783417 --- /dev/null +++ b/packages/rules/soroban/ttl/__tests__/ttl-rule.spec.ts @@ -0,0 +1,80 @@ +import { detectShortTtlValues, SorobanShortTtlRule } from '../short-ttl.rule'; +import { detectTtlIssues, SorobanTtlRule } from '../ttl.rule'; + +const SHORT_TTL_CONTRACT = ` +pub fn store(env: Env) { + env.storage().persistent().set(&DataKey::A, &1u32); + env.storage().persistent().extend_ttl(&DataKey::A, 1_000, 10_000); +} +`; + +const MIXED_CONTRACT = ` +pub fn write_config(env: Env) { + env.storage().persistent().set(&DataKey::Config, &1u32); +} + +pub fn short_bump(env: Env) { + env.storage().persistent().set(&DataKey::Short, &1u32); + env.storage().persistent().extend_ttl(&DataKey::Short, 1_000, 10_000); +} + +pub fn inverted(env: Env) { + env.storage().persistent().set(&DataKey::Inverted, &1u32); + env.storage().persistent().extend_ttl(&DataKey::Inverted, 500_000, 400_000); +} +`; + +describe('Soroban TTL rules (#885)', () => { + describe('SorobanShortTtlRule', () => { + it('reports short TTL values with the rule id and a suggestion', () => { + const warnings = detectShortTtlValues(SHORT_TTL_CONTRACT); + + expect(warnings.length).toBeGreaterThanOrEqual(1); + expect(warnings[0].ruleId).toBe('soroban-short-ttl'); + expect(warnings[0].severity).toBe('high'); + expect(warnings[0].key).toBe('DataKey::A'); + expect(warnings[0].line).toBeGreaterThan(0); + expect(warnings[0].suggestion).toBeTruthy(); + }); + + it('exposes the rule id through the rule class', () => { + expect(new SorobanShortTtlRule().evaluate(SHORT_TTL_CONTRACT)[0].ruleId).toBe( + SorobanShortTtlRule.RULE_ID, + ); + }); + + it('is clean for a well-sized extension', () => { + const warnings = detectShortTtlValues(` +pub fn healthy(env: Env) { + env.storage().persistent().extend_ttl(&DataKey::A, 17_280, 518_400); +} +`); + expect(warnings).toHaveLength(0); + }); + }); + + describe('SorobanTtlRule', () => { + it('surfaces every finding kind from the aggregate analyzer', () => { + const warnings = detectTtlIssues(MIXED_CONTRACT); + const kinds = new Set(warnings.map((warning) => warning.kind)); + + expect(kinds.has('missing_extension')).toBe(true); + expect(kinds.has('short_extend_ttl')).toBe(true); + expect(kinds.has('invalid_extension_range')).toBe(true); + expect(warnings.every((warning) => warning.suggestion.length > 0)).toBe(true); + }); + + it('returns the full analysis through getFullAnalysis', () => { + const analysis = new SorobanTtlRule().getFullAnalysis(MIXED_CONTRACT); + + expect(analysis.operations.length).toBeGreaterThan(0); + expect(analysis.metrics.totalOperations).toBe(analysis.operations.length); + expect(analysis.summary).toMatch(/TTL/); + }); + + it('exposes the aggregate rule id', () => { + expect(SorobanTtlRule.RULE_ID).toBe('soroban-ttl-analyzer'); + expect(new SorobanTtlRule().analyze(MIXED_CONTRACT).length).toBeGreaterThanOrEqual(3); + }); + }); +}); diff --git a/packages/rules/soroban/ttl/index.ts b/packages/rules/soroban/ttl/index.ts index d67175a..98f3bbb 100644 --- a/packages/rules/soroban/ttl/index.ts +++ b/packages/rules/soroban/ttl/index.ts @@ -1,2 +1,4 @@ export * from './missing-ttl-extension.rule'; export * from './excessive-ttl-extension.rule'; +export * from './short-ttl.rule'; +export * from './ttl.rule'; diff --git a/packages/rules/soroban/ttl/short-ttl.rule.ts b/packages/rules/soroban/ttl/short-ttl.rule.ts new file mode 100644 index 0000000..ef9ae3f --- /dev/null +++ b/packages/rules/soroban/ttl/short-ttl.rule.ts @@ -0,0 +1,57 @@ +/** + * Rule: soroban-short-ttl (#885) + * + * Flags `extend_ttl` calls whose `extend_to` value is unusually short, or whose + * `threshold`/`extend_to` pair can never actually extend the entry. + */ + +import { + analyzeShortTtlValues, + SOROBAN_RECOMMENDED_MIN_TTL_LEDGERS, + SOROBAN_ABSOLUTE_MIN_TTL_LEDGERS, +} from '../../../analyzers/soroban/ttl'; +import type { ShortTtlOptions, TtlFinding } from '../../../analyzers/soroban/ttl'; + +export interface ShortTtlRuleWarning { + line: number; + ruleId: string; + severity: 'high' | 'medium' | 'low'; + key?: string; + functionName?: string; + message: string; + suggestion: string; +} + +function toWarning(finding: TtlFinding): ShortTtlRuleWarning { + return { + line: finding.line, + ruleId: finding.ruleId, + severity: finding.severity, + key: finding.key, + functionName: finding.functionName, + message: finding.message, + suggestion: finding.recommendation, + }; +} + +export function detectShortTtlValues( + source: string, + options: ShortTtlOptions = {}, +): ShortTtlRuleWarning[] { + return analyzeShortTtlValues(source, options).map(toWarning); +} + +export class SorobanShortTtlRule { + public static readonly RULE_ID = 'soroban-short-ttl'; + + public evaluate(source: string, options: ShortTtlOptions = {}): ShortTtlRuleWarning[] { + return detectShortTtlValues(source, options); + } +} + +// Re-export the tuning constants so consumers can configure the thresholds. +export { + analyzeShortTtlValues, + SOROBAN_RECOMMENDED_MIN_TTL_LEDGERS, + SOROBAN_ABSOLUTE_MIN_TTL_LEDGERS, +}; diff --git a/packages/rules/soroban/ttl/ttl.rule.ts b/packages/rules/soroban/ttl/ttl.rule.ts new file mode 100644 index 0000000..18367f9 --- /dev/null +++ b/packages/rules/soroban/ttl/ttl.rule.ts @@ -0,0 +1,64 @@ +/** + * Rule: soroban-ttl-analyzer (#885) + * + * Aggregate TTL rule that surfaces every finding produced by the Soroban TTL + * analyzer — missing extensions, excessive extensions, and short/invalid TTL + * values — as flat, line-addressable warnings. + */ + +import { SorobanTtlAnalyzer } from '../../../analyzers/soroban/ttl'; +import type { + ShortTtlOptions, + SorobanTtlAnalysisResult, + TtlFinding, +} from '../../../analyzers/soroban/ttl'; + +export interface TtlRuleWarning { + line: number; + ruleId: string; + severity: 'high' | 'medium' | 'low'; + key?: string; + functionName?: string; + message: string; + suggestion: string; + kind: TtlFinding['kind']; +} + +export class SorobanTtlRule { + public static readonly RULE_ID = 'soroban-ttl-analyzer'; + + private readonly analyzer: SorobanTtlAnalyzer; + + constructor() { + this.analyzer = new SorobanTtlAnalyzer(); + } + + public analyze(source: string, options: ShortTtlOptions = {}): TtlRuleWarning[] { + return this.analyzer.analyze(source, options).findings.map( + (finding): TtlRuleWarning => ({ + line: finding.line, + ruleId: finding.ruleId, + severity: finding.severity, + key: finding.key, + functionName: finding.functionName, + message: finding.message, + suggestion: finding.recommendation, + kind: finding.kind, + }), + ); + } + + public getFullAnalysis( + source: string, + options: ShortTtlOptions = {}, + ): SorobanTtlAnalysisResult { + return this.analyzer.analyze(source, options); + } +} + +export function detectTtlIssues( + source: string, + options: ShortTtlOptions = {}, +): TtlRuleWarning[] { + return new SorobanTtlRule().analyze(source, options); +}