diff --git a/src/cache/assets/stellar/index.ts b/src/cache/assets/stellar/index.ts new file mode 100644 index 00000000..d8c0c2d2 --- /dev/null +++ b/src/cache/assets/stellar/index.ts @@ -0,0 +1,9 @@ +export { + SorobanAssetMetadataCache, + sorobanAssetMetadataCache, +} from './soroban-asset-metadata-cache'; +export type { + SorobanAssetMetadata, + SorobanAssetMetadataCacheConfig, + SorobanAssetMetadataCacheStats, +} from './soroban-asset-metadata-cache'; \ No newline at end of file diff --git a/src/cache/assets/stellar/soroban-asset-metadata-cache.ts b/src/cache/assets/stellar/soroban-asset-metadata-cache.ts new file mode 100644 index 00000000..4984c52e --- /dev/null +++ b/src/cache/assets/stellar/soroban-asset-metadata-cache.ts @@ -0,0 +1,241 @@ +/** + * Soroban Asset Metadata Cache + * + * Caches asset metadata (decimals, name, symbol, issuer, etc.) from Soroban + * contracts to avoid repeated RPC calls. Uses a Map-backed TTL cache with + * LRU eviction and background cleanup. + */ + +export interface SorobanAssetMetadata { + contractId: string; + name: string; + symbol: string; + decimals: number; + issuer?: string; + isNative: boolean; + /** Unix timestamp when this metadata was fetched */ + fetchedAt: number; +} + +export interface SorobanAssetMetadataCacheConfig { + /** Maximum number of entries in the cache */ + maxEntries?: number; + /** Time-to-live in milliseconds */ + ttlMs?: number; + /** Background cleanup interval in milliseconds */ + cleanupIntervalMs?: number; +} + +const DEFAULT_CONFIG: Required = { + maxEntries: 10_000, + ttlMs: 24 * 60 * 60 * 1000, // 24 hours - asset metadata rarely changes + cleanupIntervalMs: 60 * 60 * 1000, // 1 hour +}; + +export class SorobanAssetMetadataCache { + private cache: Map = new Map(); + private accessOrder: string[] = []; + private readonly config: Required; + private cleanupTimer?: ReturnType; + + constructor(config: SorobanAssetMetadataCacheConfig = {}) { + this.config = { ...DEFAULT_CONFIG, ...config }; + this.startCleanup(); + } + + /** + * Get cached asset metadata by contract ID. + * Returns null if not found or expired. + */ + get(contractId: string): SorobanAssetMetadata | null { + const entry = this.cache.get(contractId); + if (!entry) return null; + + if (this.isExpired(entry)) { + this.cache.delete(contractId); + this.removeFromAccessOrder(contractId); + return null; + } + + // Update access order (LRU) + this.updateAccessOrder(contractId); + return entry; + } + + /** + * Check if fresh (non-expired) metadata exists for the contract ID. + */ + has(contractId: string): boolean { + const entry = this.cache.get(contractId); + if (!entry) return false; + if (this.isExpired(entry)) { + this.cache.delete(contractId); + this.removeFromAccessOrder(contractId); + return false; + } + return true; + } + + /** + * Store asset metadata in the cache. + * Evicts oldest entry if cache is full. + */ + set(metadata: SorobanAssetMetadata): void { + if (!this.cache.has(metadata.contractId) && this.cache.size >= this.config.maxEntries) { + this.evictOldest(); + } + this.cache.set(metadata.contractId, metadata); + this.updateAccessOrder(metadata.contractId); + } + + /** + * Invalidate a specific contract ID. + */ + invalidate(contractId: string): boolean { + this.removeFromAccessOrder(contractId); + return this.cache.delete(contractId); + } + + /** + * Invalidate all entries for a specific issuer. + */ + invalidateByIssuer(issuer: string): number { + let removed = 0; + for (const [contractId, metadata] of this.cache) { + if (metadata.issuer === issuer) { + this.cache.delete(contractId); + this.removeFromAccessOrder(contractId); + removed++; + } + } + return removed; + } + + /** + * Invalidate all entries matching a predicate. + */ + invalidateBy(predicate: (metadata: SorobanAssetMetadata) => boolean): number { + let removed = 0; + for (const [contractId, metadata] of this.cache) { + if (predicate(metadata)) { + this.cache.delete(contractId); + this.removeFromAccessOrder(contractId); + removed++; + } + } + return removed; + } + + /** + * Clear all entries. + */ + clear(): void { + this.cache.clear(); + this.accessOrder.length = 0; + } + + /** + * Get current cache size. + */ + get size(): number { + return this.cache.size; + } + + /** + * Get all cached metadata (fresh only). + */ + getAll(): SorobanAssetMetadata[] { + const result: SorobanAssetMetadata[] = []; + for (const [contractId] of this.accessOrder) { + const entry = this.cache.get(contractId); + if (entry && !this.isExpired(entry)) { + result.push(entry); + } + } + return result; + } + + /** + * Get cache statistics. + */ + getStats(): SorobanAssetMetadataCacheStats { + let fresh = 0; + let expired = 0; + for (const [, metadata] of this.cache) { + if (this.isExpired(metadata)) { + expired++; + } else { + fresh++; + } + } + return { + total: this.cache.size, + fresh, + expired, + maxEntries: this.config.maxEntries, + ttlMs: this.config.ttlMs, + }; + } + + /** + * Stop the background cleanup timer. + */ + destroy(): void { + if (this.cleanupTimer) { + clearInterval(this.cleanupTimer); + this.cleanupTimer = undefined; + } + } + + private isExpired(metadata: SorobanAssetMetadata): boolean { + return Date.now() - metadata.fetchedAt > this.config.ttlMs; + } + + private updateAccessOrder(contractId: string): void { + this.removeFromAccessOrder(contractId); + this.accessOrder.push(contractId); + } + + private removeFromAccessOrder(contractId: string): void { + const index = this.accessOrder.indexOf(contractId); + if (index !== -1) { + this.accessOrder.splice(index, 1); + } + } + + private evictOldest(): void { + const oldest = this.accessOrder.shift(); + if (oldest) { + this.cache.delete(oldest); + } + } + + private startCleanup(): void { + this.cleanupTimer = setInterval(() => { + this.cleanup(); + }, this.config.cleanupIntervalMs); + if (typeof (this.cleanupTimer as { unref?: () => void }).unref === 'function') { + (this.cleanupTimer as { unref: () => void }).unref(); + } + } + + private cleanup(): void { + const now = Date.now(); + for (const [contractId, metadata] of this.cache) { + if (now - metadata.fetchedAt > this.config.ttlMs) { + this.cache.delete(contractId); + this.removeFromAccessOrder(contractId); + } + } + } +} + +export interface SorobanAssetMetadataCacheStats { + total: number; + fresh: number; + expired: number; + maxEntries: number; + ttlMs: number; +} + +export const sorobanAssetMetadataCache = new SorobanAssetMetadataCache(); \ No newline at end of file diff --git a/src/indexing/events/stellar/index.ts b/src/indexing/events/stellar/index.ts new file mode 100644 index 00000000..4caab653 --- /dev/null +++ b/src/indexing/events/stellar/index.ts @@ -0,0 +1,13 @@ +export { + SorobanBridgeEventIndexer, + sorobanBridgeEventIndexer, +} from './soroban-bridge-event-indexer'; +export type { + SorobanBridgeEvent, + SorobanBridgeEventType, + SorobanBridgeEventFilter, + SorobanBridgeEventQueryOptions, + SorobanBridgeEventQueryResult, + SorobanBridgeEventIndexerConfig, + SorobanBridgeEventIndexerStats, +} from './soroban-bridge-event-indexer'; \ No newline at end of file diff --git a/src/indexing/events/stellar/soroban-bridge-event-indexer.ts b/src/indexing/events/stellar/soroban-bridge-event-indexer.ts new file mode 100644 index 00000000..df7bc9b7 --- /dev/null +++ b/src/indexing/events/stellar/soroban-bridge-event-indexer.ts @@ -0,0 +1,287 @@ +/** + * Soroban Bridge Event Indexer + * + * Indexes bridge events (deposits, withdrawals, swaps, etc.) emitted by + * Soroban bridge contracts for querying and analysis. + */ + +export interface SorobanBridgeEvent { + eventId: string; + contractId: string; + eventType: SorobanBridgeEventType; + transactionHash: string; + ledger: number; + timestamp: number; + /** Event topics as emitted by the contract */ + topics: string[]; + /** Event data (decoded or raw) */ + data: Record; + /** Indexed fields for querying */ + indexed: SorobanBridgeEventIndexedFields; +} + +export type SorobanBridgeEventType = + | 'deposit' + | 'withdrawal' + | 'swap' + | 'route_update' + | 'fee_update' + | 'pause' + | 'unpause' + | 'ownership_transfer' + | 'bridge_transfer' + | 'unknown'; + +export interface SorobanBridgeEventIndexedFields { + /** Source chain for cross-chain operations */ + sourceChain?: string; + /** Destination chain for cross-chain operations */ + destinationChain?: string; + /** Asset involved (contract ID or native) */ + asset?: string; + /** Amount in stroops/wei */ + amount?: string; + /** Sender address */ + from?: string; + /** Recipient address */ + to?: string; + /** Bridge-specific route ID */ + routeId?: string; + /** Sender address on destination chain */ + destinationRecipient?: string; +} + +export interface SorobanBridgeEventFilter { + eventId?: string; + contractId?: string; + eventType?: SorobanBridgeEventType; + transactionHash?: string; + ledger?: number; + minTimestamp?: number; + maxTimestamp?: number; + asset?: string; + from?: string; + to?: string; + routeId?: string; + sourceChain?: string; + destinationChain?: string; +} + +export interface SorobanBridgeEventQueryOptions { + limit?: number; + offset?: number; + sortBy?: 'timestamp' | 'ledger'; + order?: 'asc' | 'desc'; +} + +export interface SorobanBridgeEventQueryResult { + total: number; + items: SorobanBridgeEvent[]; +} + +export class SorobanBridgeEventIndexer { + private readonly events = new Map(); + private readonly insertionOrder: string[] = []; + + /** + * Index a bridge event. + * If an event with the same eventId already exists, it will be updated. + */ + indexEvent(event: Omit & { eventId?: string }): SorobanBridgeEvent { + const eventId = event.eventId ?? this.generateEventId(event); + const storedEvent: SorobanBridgeEvent = { + ...event, + eventId, + indexed: event.indexed ?? this.extractIndexedFields(event), + }; + + const exists = this.events.has(eventId); + this.events.set(eventId, storedEvent); + + if (!exists) { + this.insertionOrder.push(eventId); + } + + return storedEvent; + } + + /** + * Index multiple bridge events in bulk. + */ + bulkIndexEvents( + events: Array & { eventId?: string }>, + ): SorobanBridgeEvent[] { + return events.map((event) => this.indexEvent(event)); + } + + /** + * Get an indexed event by event ID. + */ + getEvent(eventId: string): SorobanBridgeEvent | null { + return this.events.get(eventId) ?? null; + } + + /** + * Check if an event is indexed. + */ + hasEvent(eventId: string): boolean { + return this.events.has(eventId); + } + + /** + * Remove an indexed event. + */ + removeEvent(eventId: string): boolean { + const removed = this.events.delete(eventId); + if (removed) { + const index = this.insertionOrder.indexOf(eventId); + if (index !== -1) { + this.insertionOrder.splice(index, 1); + } + } + return removed; + } + + /** + * Clear all indexed events. + */ + clear(): void { + this.events.clear(); + this.insertionOrder.length = 0; + } + + /** + * Query indexed events with filters and pagination. + */ + queryEvents( + filter: SorobanBridgeEventFilter = {}, + options: SorobanBridgeEventQueryOptions = {}, + ): SorobanBridgeEventQueryResult { + const entries = this.insertionOrder + .map((eventId) => this.events.get(eventId)) + .filter((item): item is SorobanBridgeEvent => Boolean(item)); + + const filtered = entries.filter((event) => this.matchesFilter(event, filter)); + + const sorted = filtered.slice().sort((a, b) => { + const order = options.order === 'asc' ? 1 : -1; + const sortBy = options.sortBy ?? 'timestamp'; + if (sortBy === 'ledger') { + return order * (a.ledger - b.ledger); + } + return order * (a.timestamp - b.timestamp); + }); + + const offset = Math.max(0, options.offset ?? 0); + const limit = options.limit != null ? Math.max(0, options.limit) : sorted.length; + const items = sorted.slice(offset, offset + limit); + + return { + total: filtered.length, + items, + }; + } + + /** + * Get events by contract ID. + */ + getEventsByContract(contractId: string): SorobanBridgeEvent[] { + return this.queryEvents({ contractId }).items; + } + + /** + * Get events by type. + */ + getEventsByType(eventType: SorobanBridgeEventType): SorobanBridgeEvent[] { + return this.queryEvents({ eventType }).items; + } + + /** + * Get events for a specific asset. + */ + getEventsByAsset(asset: string): SorobanBridgeEvent[] { + return this.queryEvents({ asset }).items; + } + + /** + * Get events in a ledger range. + */ + getEventsByLedgerRange(minLedger: number, maxLedger: number): SorobanBridgeEvent[] { + return this.queryEvents({ minTimestamp: minLedger, maxTimestamp: maxLedger }).items; + } + + /** + * Get total number of indexed events. + */ + get size(): number { + return this.events.size; + } + + private generateEventId(event: Omit): string { + return `${event.transactionHash}:${event.ledger}:${event.topics.join(':')}`; + } + + private extractIndexedFields(event: Omit): SorobanBridgeEventIndexedFields { + const topics = event.topics; + const data = event.data; + + // Extract common indexed fields from topics and data + // Topic 0 is typically the event type signature + // Topics 1-3 are typically indexed parameters + return { + sourceChain: (data.sourceChain as string) ?? (topics[1] as string), + destinationChain: (data.destinationChain as string) ?? (topics[2] as string), + asset: (data.asset as string) ?? (topics[3] as string), + amount: data.amount as string, + from: data.from as string, + to: data.to as string, + routeId: data.routeId as string, + destinationRecipient: data.destinationRecipient as string, + }; + } + + private matchesFilter(event: SorobanBridgeEvent, filter: SorobanBridgeEventFilter): boolean { + if (filter.eventId && event.eventId !== filter.eventId) { + return false; + } + if (filter.contractId && event.contractId !== filter.contractId) { + return false; + } + if (filter.eventType && event.eventType !== filter.eventType) { + return false; + } + if (filter.transactionHash && event.transactionHash !== filter.transactionHash) { + return false; + } + if (filter.ledger != null && event.ledger !== filter.ledger) { + return false; + } + if (filter.minTimestamp != null && event.timestamp < filter.minTimestamp) { + return false; + } + if (filter.maxTimestamp != null && event.timestamp > filter.maxTimestamp) { + return false; + } + if (filter.asset && event.indexed.asset !== filter.asset) { + return false; + } + if (filter.from && event.indexed.from !== filter.from) { + return false; + } + if (filter.to && event.indexed.to !== filter.to) { + return false; + } + if (filter.routeId && event.indexed.routeId !== filter.routeId) { + return false; + } + if (filter.sourceChain && event.indexed.sourceChain !== filter.sourceChain) { + return false; + } + if (filter.destinationChain && event.indexed.destinationChain !== filter.destinationChain) { + return false; + } + return true; + } +} + +export const sorobanBridgeEventIndexer = new SorobanBridgeEventIndexer(); \ No newline at end of file diff --git a/src/scanning/routes/stellar/index.ts b/src/scanning/routes/stellar/index.ts new file mode 100644 index 00000000..2cc060f9 --- /dev/null +++ b/src/scanning/routes/stellar/index.ts @@ -0,0 +1,12 @@ +export { + StellarRouteAvailabilityScanner, + stellarRouteAvailabilityScanner, +} from './stellar-route-availability-scanner'; +export type { + StellarRoute, + StellarAsset, + StellarRouteAvailabilityScannerConfig, + StellarRouteAvailabilityFilter, + StellarRouteAvailabilityQueryOptions, + StellarRouteAvailabilityQueryResult, +} from './stellar-route-availability-scanner'; \ No newline at end of file diff --git a/src/scanning/routes/stellar/stellar-route-availability-scanner.ts b/src/scanning/routes/stellar/stellar-route-availability-scanner.ts new file mode 100644 index 00000000..23400b6f --- /dev/null +++ b/src/scanning/routes/stellar/stellar-route-availability-scanner.ts @@ -0,0 +1,216 @@ +export interface StellarRoute { + routeId: string; + sourceChain: string; + destinationChain: string; + bridgeName: string; + sourceAsset: StellarAsset; + destinationAsset: string; + available: boolean; + minAmount?: string; + maxAmount?: string; + estimatedTime?: number; + fees?: Record; + scannedAt: number; + metadata?: Record; +} + +export interface StellarAsset { + code: string; + issuer?: string; +} + +export interface StellarRouteAvailabilityScannerConfig { + supportedBridges?: string[]; + supportedSourceChains?: string[]; + supportedDestinationChains?: string[]; + scanIntervalMs?: number; + ttlMs?: number; + cacheSize?: number; +} + +export interface StellarRouteAvailabilityFilter { + routeId?: string; + sourceChain?: string; + destinationChain?: string; + bridgeName?: string; + sourceAsset?: string; + destinationAsset?: string; + available?: boolean; + minScannedAt?: number; + maxScannedAt?: number; +} + +export interface StellarRouteAvailabilityQueryOptions { + limit?: number; + offset?: number; + sortBy?: 'scannedAt'; + order?: 'asc' | 'desc'; +} + +export interface StellarRouteAvailabilityQueryResult { + total: number; + items: StellarRoute[]; +} + +export class StellarRouteAvailabilityScanner { + private readonly routes = new Map(); + private readonly insertionOrder: string[] = []; + private readonly config: Required; + private scanTimer?: ReturnType; + + constructor(config: StellarRouteAvailabilityScannerConfig = {}) { + this.config = { + supportedBridges: config.supportedBridges ?? ['stellar', 'soroban'], + supportedSourceChains: config.supportedSourceChains ?? ['stellar', 'ethereum', 'polygon'], + supportedDestinationChains: config.supportedDestinationChains ?? ['stellar', 'ethereum', 'polygon'], + scanIntervalMs: config.scanIntervalMs ?? 30000, + ttlMs: config.ttlMs ?? 60000, + cacheSize: config.cacheSize ?? 1000, + }; + } + + scanRoute(route: Omit): StellarRoute { + const scannedRoute: StellarRoute = { + ...route, + scannedAt: Date.now(), + }; + + const exists = this.routes.has(scannedRoute.routeId); + this.routes.set(scannedRoute.routeId, scannedRoute); + + if (!exists) { + this.insertionOrder.push(scannedRoute.routeId); + this.enforceCacheSize(); + } + + return scannedRoute; + } + + bulkScanRoutes(routes: Omit[]): StellarRoute[] { + return routes.map((route) => this.scanRoute(route)); + } + + getRoute(routeId: string): StellarRoute | null { + return this.routes.get(routeId) ?? null; + } + + hasRoute(routeId: string): boolean { + return this.routes.has(routeId); + } + + removeRoute(routeId: string): boolean { + const removed = this.routes.delete(routeId); + if (removed) { + const index = this.insertionOrder.indexOf(routeId); + if (index !== -1) { + this.insertionOrder.splice(index, 1); + } + } + return removed; + } + + clear(): void { + this.routes.clear(); + this.insertionOrder.length = 0; + } + + queryRoutes( + filter: StellarRouteAvailabilityFilter = {}, + options: StellarRouteAvailabilityQueryOptions = {}, + ): StellarRouteAvailabilityQueryResult { + const entries = this.insertionOrder + .map((routeId) => this.routes.get(routeId)) + .filter((item): item is StellarRoute => Boolean(item)); + + const filtered = entries.filter((route) => this.matchesFilter(route, filter)); + + const sorted = filtered.slice().sort((a, b) => { + const order = options.order === 'asc' ? 1 : -1; + if (options.sortBy === 'scannedAt') { + return order * (a.scannedAt - b.scannedAt); + } + return order * (a.scannedAt - b.scannedAt); + }); + + const offset = Math.max(0, options.offset ?? 0); + const limit = options.limit != null ? Math.max(0, options.limit) : sorted.length; + const items = sorted.slice(offset, offset + limit); + + return { + total: filtered.length, + items, + }; + } + + startScanning(scanFn: () => Promise): void { + if (this.scanTimer) { + return; + } + + const runScan = async () => { + try { + const routes = await scanFn(); + this.bulkScanRoutes(routes); + } catch { + // Scan errors are silently ignored to avoid breaking the scanner loop + } + }; + + runScan(); + this.scanTimer = setInterval(runScan, this.config.scanIntervalMs); + if (typeof (this.scanTimer as { unref?: () => void }).unref === 'function') { + (this.scanTimer as { unref: () => void }).unref(); + } + } + + stopScanning(): void { + if (this.scanTimer) { + clearInterval(this.scanTimer); + this.scanTimer = undefined; + } + } + + private matchesFilter(route: StellarRoute, filter: StellarRouteAvailabilityFilter): boolean { + if (filter.routeId && route.routeId !== filter.routeId) { + return false; + } + if (filter.sourceChain && route.sourceChain !== filter.sourceChain) { + return false; + } + if (filter.destinationChain && route.destinationChain !== filter.destinationChain) { + return false; + } + if (filter.bridgeName && route.bridgeName !== filter.bridgeName) { + return false; + } + if (filter.sourceAsset && route.sourceAsset.code !== filter.sourceAsset) { + return false; + } + if (filter.destinationAsset && route.destinationAsset !== filter.destinationAsset) { + return false; + } + if (filter.available !== undefined && route.available !== filter.available) { + return false; + } + if (filter.minScannedAt != null && route.scannedAt < filter.minScannedAt) { + return false; + } + if (filter.maxScannedAt != null && route.scannedAt > filter.maxScannedAt) { + return false; + } + return true; + } + + private enforceCacheSize(): void { + while (this.routes.size > this.config.cacheSize) { + const oldestKey = this.insertionOrder.shift(); + if (oldestKey) { + this.routes.delete(oldestKey); + } else { + break; + } + } + } +} + +export const stellarRouteAvailabilityScanner = new StellarRouteAvailabilityScanner(); \ No newline at end of file diff --git a/src/storage/artifacts/stellar/index.ts b/src/storage/artifacts/stellar/index.ts new file mode 100644 index 00000000..10119106 --- /dev/null +++ b/src/storage/artifacts/stellar/index.ts @@ -0,0 +1,13 @@ +export { + SorobanAnalysisArtifactStorage, + sorobanAnalysisArtifactStorage, +} from './soroban-analysis-artifact-storage'; +export type { + SorobanAnalysisArtifact, + SorobanAnalysisArtifactType, + SorobanAnalysisArtifactFilter, + SorobanAnalysisArtifactQueryOptions, + SorobanAnalysisArtifactQueryResult, + SorobanAnalysisArtifactStorageConfig, + SorobanAnalysisArtifactStorageStats, +} from './soroban-analysis-artifact-storage'; \ No newline at end of file diff --git a/src/storage/artifacts/stellar/soroban-analysis-artifact-storage.ts b/src/storage/artifacts/stellar/soroban-analysis-artifact-storage.ts new file mode 100644 index 00000000..d2867e1f --- /dev/null +++ b/src/storage/artifacts/stellar/soroban-analysis-artifact-storage.ts @@ -0,0 +1,477 @@ +/** + * Soroban Analysis Artifact Storage + * + * Stores analysis artifacts generated from Soroban contract analysis, + * such as ABI interpretations, security analysis results, optimization + * suggestions, and other analytical outputs. + */ + +export interface SorobanAnalysisArtifact { + id: string; + contractId: string; + artifactType: SorobanAnalysisArtifactType; + title: string; + description?: string; + content: unknown; // JSON-serializable analysis result + metadata?: Record; + createdAt: number; + updatedAt: number; + version: number; + tags: string[]; +} + +export type SorobanAnalysisArtifactType = + | 'abi_interpretation' + | 'security_analysis' + | 'gas_optimization' + | 'storage_layout' + | 'inheritance_graph' + | 'function_summary' + | 'event_summary' + | 'access_control_analysis' + | 'reentrancy_analysis' + | 'formal_verification' + | 'test_coverage' + | 'gas_report' + | 'compile_info' + | 'dependency_graph' + | 'license_analysis' + | 'custom'; + +export interface SorobanAnalysisArtifactFilter { + id?: string; + contractId?: string; + artifactType?: SorobanAnalysisArtifactType; + title?: string; + tags?: string[]; + minCreatedAt?: number; + maxCreatedAt?: number; + minUpdatedAt?: number; + maxUpdatedAt?: number; + version?: number; + contentContains?: Record; + metadataContains?: Record; +} + +export interface SorobanAnalysisArtifactQueryOptions { + limit?: number; + offset?: number; + sortBy?: 'createdAt' | 'updatedAt' | 'version'; + order?: 'asc' | 'desc'; +} + +export interface SorobanAnalysisArtifactQueryResult { + total: number; + items: SorobanAnalysisArtifact[]; +} + +export interface SorobanAnalysisArtifactStorageConfig { + /** Maximum number of artifacts to store per contract */ + maxArtifactsPerContract?: number; + /** Enable automatic cleanup of old artifacts */ + cleanupEnabled?: boolean; + /** Maximum age of artifacts to retain (ms) */ + maxArtifactAgeMs?: number; + /** Background cleanup interval (ms) */ + cleanupIntervalMs?: number; +} + +const DEFAULT_CONFIG: Required = { + maxArtifactsPerContract: 100, + cleanupEnabled: true, + maxArtifactAgeMs: 30 * 24 * 60 * 60 * 1000, // 30 days + cleanupIntervalMs: 60 * 60 * 1000, // 1 hour +}; + +export class SorobanAnalysisArtifactStorage { + private readonly artifacts = new Map>(); // contractId -> artifactId -> artifact + private readonly config: Required; + private cleanupTimer?: ReturnType; + + constructor(config: SorobanAnalysisArtifactStorageConfig = {}) { + this.config = { ...DEFAULT_CONFIG, ...config }; + if (this.config.cleanupEnabled) { + this.startCleanup(); + } + } + + /** + * Store an analysis artifact. + * If an artifact with the same ID exists, it will be updated. + */ + storeArtifact( + artifact: Omit & { + createdAt?: number; + updatedAt?: number; + }, + ): SorobanAnalysisArtifact { + const now = Date.now(); + const storedArtifact: SorobanAnalysisArtifact = { + ...artifact, + createdAt: artifact.createdAt ?? now, + updatedAt: artifact.updatedAt ?? now, + content: artifact.content ?? {}, + metadata: artifact.metadata ?? {}, + tags: artifact.tags ?? [], + version: artifact.version ?? 1, + }; + + // Get or create the contract's artifact map + let contractArtifacts = this.artifacts.get(artifact.contractId); + if (!contractArtifacts) { + contractArtifacts = new Map(); + this.artifacts.set(artifact.contractId, contractArtifacts); + } + + // Check if we need to enforce the limit + const isUpdate = contractArtifacts.has(artifact.id); + if (!isUpdate && this.config.maxArtifactsPerContract > 0) { + this.enforceLimitPerContract(artifact.contractId); + } + + // Store/update the artifact + contractArtifacts.set(artifact.id, storedArtifact); + return storedArtifact; + } + + /** + * Batch store multiple artifacts. + */ + batchStoreArtifacts( + artifacts: Array< + Omit & { + createdAt?: number; + updatedAt?: number; + } + >, + ): SorobanAnalysisArtifact[] { + return artifacts.map((artifact) => this.storeArtifact(artifact)); + } + + /** + * Retrieve an artifact by its ID and contract ID. + */ + getArtifact(contractId: string, artifactId: string): SorobanAnalysisArtifact | null { + const contractArtifacts = this.artifacts.get(contractId); + if (!contractArtifacts) return null; + return contractArtifacts.get(artifactId) ?? null; + } + + /** + * Check if an artifact exists. + */ + hasArtifact(contractId: string, artifactId: string): boolean { + const contractArtifacts = this.artifacts.get(contractId); + if (!contractArtifacts) return false; + return contractArtifacts.has(artifactId); + } + + /** + * Remove an artifact by ID and contract ID. + */ + removeArtifact(contractId: string, artifactId: string): boolean { + const contractArtifacts = this.artifacts.get(contractId); + if (!contractArtifacts) return false; + const removed = contractArtifacts.delete(artifactId); + if (contractArtifacts.size === 0) { + this.artifacts.delete(contractId); + } + return removed; + } + + /** + * Remove all artifacts for a specific contract. + */ + removeArtifactsByContract(contractId: string): number { + const contractArtifacts = this.artifacts.get(contractId); + if (!contractArtifacts) return 0; + const count = contractArtifacts.size; + contractArtifacts.clear(); + this.artifacts.delete(contractId); + return count; + } + + /** + * Remove all artifacts matching a filter. + */ + removeArtifactsByFilter(filter: SorobanAnalysisArtifactFilter): number { + let removed = 0; + for (const [contractId, contractArtifacts] of this.artifacts) { + const toRemove: string[] = []; + for (const [artifactId, artifact] of contractArtifacts) { + if (this.matchesFilter(artifact, filter)) { + toRemove.push(artifactId); + } + } + for (const artifactId of toRemove) { + contractArtifacts.delete(artifactId); + removed++; + } + if (contractArtifacts.size === 0) { + this.artifacts.delete(contractId); + } + } + return removed; + } + + /** + * Get all artifacts for a specific contract. + */ + getArtifactsByContract(contractId: string): SorobanAnalysisArtifact[] { + const contractArtifacts = this.artifacts.get(contractId); + if (!contractArtifacts) return []; + return Array.from(contractArtifacts.values()); + } + + /** + * Get artifacts by type. + */ + getArtifactsByType(artifactType: SorobanAnalysisArtifactType): SorobanAnalysisArtifact[] { + const result: SorobanAnalysisArtifact[] = []; + for (const [, contractArtifacts] of this.artifacts) { + for (const artifact of contractArtifacts.values()) { + if (artifact.artifactType === artifactType) { + result.push(artifact); + } + } + } + return result; + } + + /** + * Get artifacts by tag. + */ + getArtifactsByTag(tag: string): SorobanAnalysisArtifact[] { + const result: SorobanAnalysisArtifact[] = []; + for (const [, contractArtifacts] of this.artifacts) { + for (const artifact of contractArtifacts.values()) { + if (artifact.tags.includes(tag)) { + result.push(artifact); + } + } + } + return result; + } + + /** + * Clear all stored artifacts. + */ + clear(): void { + this.artifacts.clear(); + } + + /** + * Get total count of stored artifacts. + */ + get size(): number { + let count = 0; + for (const [, contractArtifacts] of this.artifacts) { + count += contractArtifacts.size; + } + return count; + } + + /** + * Query artifacts with filtering and pagination. + */ + queryArtifacts( + filter: SorobanAnalysisArtifactFilter = {}, + options: SorobanAnalysisArtifactQueryOptions = {}, + ): SorobanAnalysisArtifactQueryResult { + // Collect all artifacts + const allArtifacts: SorobanAnalysisArtifact[] = []; + for (const [, contractArtifacts] of this.artifacts) { + for (const artifact of contractArtifacts.values()) { + allArtifacts.push(artifact); + } + } + + // Apply filters + const filtered = allArtifacts.filter((artifact) => + this.matchesFilter(artifact, filter), + ); + + // Apply sorting + const sorted = filtered.slice().sort((a, b) => { + const order = options.order === 'asc' ? 1 : -1; + const sortBy = options.sortBy ?? 'createdAt'; + return order * (a[sortBy as keyof SorobanAnalysisArtifact] - b[sortBy as keyof SorobanAnalysisArtifact]); + }); + + // Apply pagination + const offset = Math.max(0, options.offset ?? 0); + const limit = options.limit != null ? Math.max(0, options.limit) : sorted.length; + const items = sorted.slice(offset, offset + limit); + + return { + total: filtered.length, + items, + }; + } + + /** + * Get storage statistics. + */ + getStats(): SorobanAnalysisArtifactStorageStats { + let total = 0; + const byType: Record = {} as Record< + SorobanAnalysisArtifactType, + number + >; + const byContract: Record = {}; + + for (const [contractId, contractArtifacts] of this.artifacts) { + byContract[contractId] = contractArtifacts.size; + total += contractArtifacts.size; + for (const artifact of contractArtifacts.values()) { + byType[artifact.artifactType] = + (byType[artifact.artifactType] || 0) + 1; + } + } + + return { + total, + byType, + byContract, + config: this.config, + }; + } + + /** + * Stop the background cleanup timer. + */ + destroy(): void { + if (this.cleanupTimer) { + clearInterval(this.cleanupTimer); + this.cleanupTimer = undefined; + } + } + + private matchesFilter( + artifact: SorobanAnalysisArtifact, + filter: SorobanAnalysisArtifactFilter, + ): boolean { + if (filter.id && artifact.id !== filter.id) { + return false; + } + if (filter.contractId && artifact.contractId !== filter.contractId) { + return false; + } + if (filter.artifactType && artifact.artifactType !== filter.artifactType) { + return false; + } + if (filter.title && !artifact.title.includes(filter.title)) { + return false; + } + if (filter.tags && !filter.tags.some((tag) => artifact.tags.includes(tag))) { + return false; + } + if (filter.minCreatedAt != null && artifact.createdAt < filter.minCreatedAt) { + return false; + } + if (filter.maxCreatedAt != null && artifact.createdAt > filter.maxCreatedAt) { + return false; + } + if (filter.minUpdatedAt != null && artifact.updatedAt < filter.minUpdatedAt) { + return false; + } + if (filter.maxUpdatedAt != null && artifact.updatedAt > filter.maxUpdatedAt) { + return false; + } + if (filter.version != null && artifact.version !== filter.version) { + return false; + } + if (filter.contentContains && !this.matchesContent(artifact.content, filter.contentContains)) { + return false; + } + if (filter.metadataContains && !this.matchesMetadata(artifact.metadata, filter.metadataContains)) { + return false; + } + return true; + } + + private matchesContent(content: unknown, filter: Record): boolean { + if (typeof content !== 'object' || content === null || Array.isArray(content)) { + return false; + } + return Object.entries(filter).every(([key, value]) => { + // Simple equality check for primitive values + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return (content as Record)[key] === value; + } + // For objects/arrays, we'd need deep equality, but for simplicity we'll do a basic check + return true; + }); + } + + private matchesMetadata( + metadata: Record, + filter: Record, + ): boolean { + return Object.entries(filter).every(([key, value]) => { + if (!(key in metadata)) { + return false; + } + // Simple equality check + return metadata[key] === value; + }); + } + + private enforceLimitPerContract(contractId: string): void { + const contractArtifacts = this.artifacts.get(contractId); + if (!contractArtifacts) return; + + // Convert to array and sort by updatedAt (oldest first) + const artifactsArray = Array.from(contractArtifacts.values()).sort( + (a, b) => a.updatedAt - b.updatedAt, + ); + + // Remove oldest artifacts if we exceed the limit + while ( + contractArtifacts.size >= this.config.maxArtifactsPerContract && + artifactsArray.length > 0 + ) { + const oldest = artifactsArray.shift(); + if (oldest) { + contractArtifacts.delete(oldest.id); + } + } + } + + private startCleanup(): void { + if (!this.config.cleanupEnabled) return; + this.cleanupTimer = setInterval(() => { + this.cleanup(); + }, this.config.cleanupIntervalMs); + if (typeof (this.cleanupTimer as { unref?: () => void }).unref === 'function') { + (this.cleanupTimer as { unref: () => void }).unref(); + } + } + + private cleanup(): void { + const now = Date.now(); + for (const [contractId, contractArtifacts] of this.artifacts) { + const toRemove: string[] = []; + for (const [artifactId, artifact] of contractArtifacts) { + if (now - artifact.updatedAt > this.config.maxArtifactAgeMs) { + toRemove.push(artifactId); + } + } + for (const artifactId of toRemove) { + contractArtifacts.delete(artifactId); + } + if (contractArtifacts.size === 0) { + this.artifacts.delete(contractId); + } + } + } +} + +export interface SorobanAnalysisArtifactStorageStats { + total: number; + byType: Record; + byContract: Record; + config: Required; +} + +export const sorobanAnalysisArtifactStorage = new SorobanAnalysisArtifactStorage(); \ No newline at end of file