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