From 242e9ac9e30408ac5d63ef35f58f2568aabd76e7 Mon Sep 17 00:00:00 2001 From: a-malik-gh Date: Sun, 28 Jun 2026 09:32:23 +0000 Subject: [PATCH] feat: implement Soroban asset verification, route utilization analytics, and bridge discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Issue #509: SorobanAssetVerifierService — validates issuer account ID and asset metadata, returns structured VerificationResult - Issue #510: StellarRouteUtilizationService — tracks route usage events, aggregates metrics with avg daily usage, generates utilization reports - Issue #511: SorobanBridgeDiscoveryService — auto-discovers and registers bridge providers via pluggable fetch function, validates metadata, supports status queries and manual register/deregister --- src/analytics/utilization/stellar/index.ts | 1 + .../stellar/route-utilization.service.ts | 102 ++++++++--- .../stellar/route-utilization.types.ts | 35 ++++ src/discovery/providers/stellar/index.ts | 2 + .../soroban-bridge-discovery.service.ts | 158 ++++++++++++++++++ .../stellar/soroban-bridge-discovery.types.ts | 46 +++++ .../assets/stellar/asset-verifier.service.ts | 96 ++++++++--- .../assets/stellar/asset-verifier.types.ts | 44 +++++ src/verification/assets/stellar/index.ts | 1 + 9 files changed, 443 insertions(+), 42 deletions(-) create mode 100644 src/analytics/utilization/stellar/route-utilization.types.ts create mode 100644 src/discovery/providers/stellar/index.ts create mode 100644 src/discovery/providers/stellar/soroban-bridge-discovery.service.ts create mode 100644 src/discovery/providers/stellar/soroban-bridge-discovery.types.ts create mode 100644 src/verification/assets/stellar/asset-verifier.types.ts diff --git a/src/analytics/utilization/stellar/index.ts b/src/analytics/utilization/stellar/index.ts index ac83ef43..f9fb3437 100644 --- a/src/analytics/utilization/stellar/index.ts +++ b/src/analytics/utilization/stellar/index.ts @@ -1 +1,2 @@ export * from './route-utilization.service'; +export * from './route-utilization.types'; diff --git a/src/analytics/utilization/stellar/route-utilization.service.ts b/src/analytics/utilization/stellar/route-utilization.service.ts index e6e6b004..ec1b3037 100644 --- a/src/analytics/utilization/stellar/route-utilization.service.ts +++ b/src/analytics/utilization/stellar/route-utilization.service.ts @@ -1,45 +1,105 @@ -import { Injectable, Logger } from '@nestjs/common'; +/** + * Stellar Route Utilization Analytics Service + * + * Tracks how frequently each bridge route is used, aggregates utilization + * metrics over time, and generates usage reports so operators can identify + * popular routes and trends. + * + * @see Issue #510 — Implement Stellar Route Utilization Analytics + */ -export interface RouteUtilizationMetrics { - routeId: string; - usageCount: number; - lastUsed: Date; -} +import type { + RouteUsageEntry, + RouteUtilizationMetrics, + UtilizationReport, + TrackUsageOptions, + RouteUtilizationConfig, +} from './route-utilization.types'; -export interface UtilizationReport { - generatedAt: Date; - metrics: RouteUtilizationMetrics[]; -} +const MS_PER_DAY = 86_400_000; -@Injectable() export class StellarRouteUtilizationService { - private readonly logger = new Logger(StellarRouteUtilizationService.name); - private usageMap = new Map(); + private readonly usageMap = new Map(); + private readonly now: () => number; + + constructor(config: RouteUtilizationConfig = {}) { + this.now = config.now ?? (() => Date.now()); + } - trackRouteUsage(routeId: string): void { - this.logger.log(`Tracking usage for route ${routeId}`); + // ─── Tracking ────────────────────────────────────────────────────────────── + + /** + * Record a single usage event for `routeId`. + * Creates a new entry if the route has not been seen before. + */ + trackRouteUsage(routeId: string, options: TrackUsageOptions = {}): void { + const ts = this.now(); + const volumeUsd = options.volumeUsd ?? 0; const existing = this.usageMap.get(routeId); + if (existing) { existing.usageCount++; - existing.lastUsed = new Date(); + existing.lastUsed = ts; + existing.totalVolumeUsd += volumeUsd; } else { this.usageMap.set(routeId, { routeId, usageCount: 1, - lastUsed: new Date() + firstUsed: ts, + lastUsed: ts, + totalVolumeUsd: volumeUsd, }); } } + // ─── Aggregation ────────────────────────────────────────────────────────── + + /** + * Return computed metrics for all tracked routes, + * sorted by usageCount descending. + */ aggregateMetrics(): RouteUtilizationMetrics[] { - return Array.from(this.usageMap.values()); + return Array.from(this.usageMap.values()) + .map((entry) => this.toMetrics(entry)) + .sort((a, b) => b.usageCount - a.usageCount); + } + + /** + * Return metrics for a specific route, or `undefined` if not tracked. + */ + getRouteMetrics(routeId: string): RouteUtilizationMetrics | undefined { + const entry = this.usageMap.get(routeId); + return entry ? this.toMetrics(entry) : undefined; } + // ─── Reporting ──────────────────────────────────────────────────────────── + + /** + * Generate a full utilization report with aggregate totals and per-route + * metrics sorted by popularity. + */ generateUtilizationReport(): UtilizationReport { - this.logger.log('Generating utilization report'); + const metrics = this.aggregateMetrics(); return { - generatedAt: new Date(), - metrics: this.aggregateMetrics(), + generatedAt: this.now(), + totalRoutes: metrics.length, + totalUsageCount: metrics.reduce((sum, m) => sum + m.usageCount, 0), + metrics, }; } + + /** Reset all tracked usage data. */ + reset(): void { + this.usageMap.clear(); + } + + // ─── Private helpers ────────────────────────────────────────────────────── + + private toMetrics(entry: RouteUsageEntry): RouteUtilizationMetrics { + const elapsedMs = Math.max(this.now() - entry.firstUsed, 1); + const avgDailyUsage = + (entry.usageCount / elapsedMs) * MS_PER_DAY; + + return { ...entry, avgDailyUsage }; + } } diff --git a/src/analytics/utilization/stellar/route-utilization.types.ts b/src/analytics/utilization/stellar/route-utilization.types.ts new file mode 100644 index 00000000..b7527bce --- /dev/null +++ b/src/analytics/utilization/stellar/route-utilization.types.ts @@ -0,0 +1,35 @@ +/** + * Types for Stellar Route Utilization Analytics + * @see Issue #510 — Implement Stellar Route Utilization Analytics + */ + +export interface RouteUsageEntry { + routeId: string; + usageCount: number; + firstUsed: number; + lastUsed: number; + totalVolumeUsd: number; +} + +export interface RouteUtilizationMetrics extends RouteUsageEntry { + /** Average uses per day since first recorded usage */ + avgDailyUsage: number; +} + +export interface UtilizationReport { + generatedAt: number; + totalRoutes: number; + totalUsageCount: number; + /** Routes sorted by usageCount descending */ + metrics: RouteUtilizationMetrics[]; +} + +export interface TrackUsageOptions { + /** USD-denominated volume for this single usage event. Default 0. */ + volumeUsd?: number; +} + +export interface RouteUtilizationConfig { + /** Injected clock for deterministic testing. Defaults to Date.now. */ + now?: () => number; +} diff --git a/src/discovery/providers/stellar/index.ts b/src/discovery/providers/stellar/index.ts new file mode 100644 index 00000000..19dfa219 --- /dev/null +++ b/src/discovery/providers/stellar/index.ts @@ -0,0 +1,2 @@ +export * from './soroban-bridge-discovery.service'; +export * from './soroban-bridge-discovery.types'; diff --git a/src/discovery/providers/stellar/soroban-bridge-discovery.service.ts b/src/discovery/providers/stellar/soroban-bridge-discovery.service.ts new file mode 100644 index 00000000..47acd12c --- /dev/null +++ b/src/discovery/providers/stellar/soroban-bridge-discovery.service.ts @@ -0,0 +1,158 @@ +/** + * Soroban Bridge Discovery Service + * + * Automatically discovers newly supported Soroban bridge providers, validates + * their metadata, and registers them in an in-memory registry. Duplicate + * registrations (same id) are silently skipped to keep the registry idempotent. + * + * Usage: + * const service = new SorobanBridgeDiscoveryService({ maxProviders: 50 }); + * const result = await service.discover(fetchProviderList); + * const active = service.getByStatus('active'); + * + * @see Issue #511 — Implement Soroban Bridge Discovery Service + */ + +import type { + SorobanBridgeProviderMetadata, + BridgeProviderInput, + BridgeProviderStatus, + BridgeProviderValidationResult, + BridgeDiscoveryResult, + BridgeDiscoveryConfig, +} from './soroban-bridge-discovery.types'; + +export class SorobanBridgeDiscoveryService { + private readonly registry = new Map(); + private readonly maxProviders: number; + private readonly now: () => number; + + constructor(config: BridgeDiscoveryConfig = {}) { + this.maxProviders = config.maxProviders ?? 100; + this.now = config.now ?? (() => Date.now()); + + if (this.maxProviders < 1) { + throw new RangeError('maxProviders must be ≥ 1'); + } + } + + // ─── Discovery ───────────────────────────────────────────────────────────── + + /** + * Fetch provider metadata from the supplied async function, validate each + * entry, then register all previously-unknown valid providers up to + * `maxProviders`. + * + * @param fetchFn Async function that resolves to an array of raw provider + * metadata (without `registeredAt`). + */ + async discover( + fetchFn: () => Promise, + ): Promise { + const raw = await fetchFn(); + let registered = 0; + let skipped = 0; + + for (const item of raw) { + const validation = this.validateProvider(item); + if (!validation.isValid || this.registry.has(item.id)) { + skipped++; + continue; + } + if (this.registry.size >= this.maxProviders) { + skipped++; + continue; + } + this.registry.set(item.id, { ...item, registeredAt: this.now() }); + registered++; + } + + return { discovered: raw.length, registered, skipped }; + } + + // ─── Registration ────────────────────────────────────────────────────────── + + /** + * Register a single provider directly without going through discovery. + * Returns `false` when the provider is already registered, at capacity, + * or fails validation. + */ + register(provider: BridgeProviderInput): boolean { + const validation = this.validateProvider(provider); + if (!validation.isValid) return false; + if (this.registry.has(provider.id)) return false; + if (this.registry.size >= this.maxProviders) return false; + + this.registry.set(provider.id, { ...provider, registeredAt: this.now() }); + return true; + } + + /** Remove a provider from the registry. Returns `true` if it was present. */ + deregister(id: string): boolean { + return this.registry.delete(id); + } + + // ─── Validation ──────────────────────────────────────────────────────────── + + /** + * Validate provider metadata without registering. + * Checks for required fields: id, name, endpoint, and supportedAssets. + */ + validateProvider(provider: BridgeProviderInput): BridgeProviderValidationResult { + const issues: string[] = []; + + if (!provider.id || provider.id.trim().length === 0) { + issues.push('id is required'); + } + if (!provider.name || provider.name.trim().length === 0) { + issues.push('name is required'); + } + if (!provider.endpoint || provider.endpoint.trim().length === 0) { + issues.push('endpoint is required'); + } + if ( + !Array.isArray(provider.supportedAssets) || + provider.supportedAssets.length === 0 + ) { + issues.push('supportedAssets must be a non-empty array'); + } + + return { + providerId: provider.id ?? '', + isValid: issues.length === 0, + issues, + }; + } + + // ─── Queries ─────────────────────────────────────────────────────────────── + + /** Look up a provider by id. */ + get(id: string): SorobanBridgeProviderMetadata | undefined { + return this.registry.get(id); + } + + /** All registered providers, sorted by registration time ascending. */ + getAll(): SorobanBridgeProviderMetadata[] { + return Array.from(this.registry.values()).sort( + (a, b) => a.registeredAt - b.registeredAt, + ); + } + + /** All registered providers matching a given status. */ + getByStatus(status: BridgeProviderStatus): SorobanBridgeProviderMetadata[] { + return this.getAll().filter((p) => p.status === status); + } + + /** Update the status of a registered provider. Returns `false` if not found. */ + updateStatus(id: string, status: BridgeProviderStatus): boolean { + const provider = this.registry.get(id); + if (!provider) return false; + provider.status = status; + return true; + } + + /** Number of currently registered providers. */ + get size(): number { + return this.registry.size; + } +} diff --git a/src/discovery/providers/stellar/soroban-bridge-discovery.types.ts b/src/discovery/providers/stellar/soroban-bridge-discovery.types.ts new file mode 100644 index 00000000..99f9a5d6 --- /dev/null +++ b/src/discovery/providers/stellar/soroban-bridge-discovery.types.ts @@ -0,0 +1,46 @@ +/** + * Types for Soroban Bridge Discovery Service + * @see Issue #511 — Implement Soroban Bridge Discovery Service + */ + +export type BridgeProviderStatus = 'active' | 'inactive' | 'degraded'; + +export interface SorobanBridgeProviderMetadata { + /** Unique provider identifier */ + id: string; + /** Human-readable provider name */ + name: string; + /** Soroban contract address or RPC endpoint for the bridge */ + endpoint: string; + /** Current operational status */ + status: BridgeProviderStatus; + /** Asset codes supported by this bridge (e.g. ["USDC", "XLM"]) */ + supportedAssets: string[]; + /** Unix timestamp (ms) when provider was registered */ + registeredAt: number; +} + +/** Raw input for registering a provider (without server-set fields) */ +export type BridgeProviderInput = Omit< + SorobanBridgeProviderMetadata, + 'registeredAt' +>; + +export interface BridgeProviderValidationResult { + providerId: string; + isValid: boolean; + issues: string[]; +} + +export interface BridgeDiscoveryResult { + discovered: number; + registered: number; + skipped: number; +} + +export interface BridgeDiscoveryConfig { + /** Maximum number of providers to hold in registry. Default 100. */ + maxProviders?: number; + /** Injected clock for deterministic testing. Defaults to Date.now. */ + now?: () => number; +} diff --git a/src/verification/assets/stellar/asset-verifier.service.ts b/src/verification/assets/stellar/asset-verifier.service.ts index 70d8191b..81cf5fc5 100644 --- a/src/verification/assets/stellar/asset-verifier.service.ts +++ b/src/verification/assets/stellar/asset-verifier.service.ts @@ -1,39 +1,93 @@ -import { Injectable, Logger } from '@nestjs/common'; - -export interface AssetVerificationResult { - assetId: string; - isVerified: boolean; - issuerValid: boolean; - metadataValid: boolean; - reason?: string; -} +/** + * Soroban Asset Verification Service + * + * Verifies the authenticity of supported Soroban assets by validating issuer + * information and asset metadata. Returns a structured verification status so + * callers can decide whether to allow a user interaction with the asset. + * + * @see Issue #509 — Implement Soroban Asset Verification Service + */ + +import type { + SorobanAsset, + AssetVerificationResult, + AssetVerifierConfig, + VerificationStatus, +} from './asset-verifier.types'; + +// Stellar account IDs are 56-character base32 strings starting with 'G' +const STELLAR_ACCOUNT_ID_LENGTH = 56; -@Injectable() export class SorobanAssetVerifierService { - private readonly logger = new Logger(SorobanAssetVerifierService.name); + private readonly minIssuerLength: number; + private readonly now: () => number; - async verifyAsset(assetId: string, issuerId: string, metadata: any): Promise { - this.logger.log(`Verifying Soroban asset ${assetId}`); + constructor(config: AssetVerifierConfig = {}) { + this.minIssuerLength = + config.minIssuerLength ?? STELLAR_ACCOUNT_ID_LENGTH; + this.now = config.now ?? (() => Date.now()); + } + + /** + * Verify a single Soroban asset. + * + * Validates: + * - Issuer account ID format (non-empty, meets minimum length, starts with 'G') + * - Metadata completeness (must contain a non-empty `code` field) + */ + verifyAsset(asset: SorobanAsset): AssetVerificationResult { + const issuerValid = this.validateIssuer(asset.issuerId); + const metadataValid = this.validateMetadata(asset); - const issuerValid = this.validateIssuer(issuerId); - const metadataValid = this.verifyMetadata(metadata); - const isVerified = issuerValid && metadataValid; + const status: VerificationStatus = isVerified ? 'verified' : 'invalid'; + + const reason = isVerified + ? undefined + : this.buildReason(issuerValid, metadataValid); return { - assetId, + assetId: asset.id, + status, isVerified, issuerValid, metadataValid, - reason: isVerified ? undefined : 'Asset verification failed due to invalid issuer or metadata' + reason, + verifiedAt: this.now(), }; } + /** + * Verify multiple assets at once. + * Results are returned in the same order as the input array. + */ + verifyAssets(assets: SorobanAsset[]): AssetVerificationResult[] { + return assets.map((a) => this.verifyAsset(a)); + } + + // ─── Private helpers ──────────────────────────────────────────────────────── + private validateIssuer(issuerId: string): boolean { - return issuerId && issuerId.length > 0; + return ( + typeof issuerId === 'string' && + issuerId.length >= this.minIssuerLength && + issuerId.startsWith('G') + ); + } + + private validateMetadata(asset: SorobanAsset): boolean { + const { metadata } = asset; + return ( + !!metadata && + typeof metadata.code === 'string' && + metadata.code.trim().length > 0 + ); } - private verifyMetadata(metadata: any): boolean { - return !!metadata; + private buildReason(issuerValid: boolean, metadataValid: boolean): string { + const issues: string[] = []; + if (!issuerValid) issues.push('invalid issuer'); + if (!metadataValid) issues.push('invalid metadata'); + return `Verification failed: ${issues.join(', ')}`; } } diff --git a/src/verification/assets/stellar/asset-verifier.types.ts b/src/verification/assets/stellar/asset-verifier.types.ts new file mode 100644 index 00000000..5c096ce0 --- /dev/null +++ b/src/verification/assets/stellar/asset-verifier.types.ts @@ -0,0 +1,44 @@ +/** + * Types for Soroban Asset Verification Service + * @see Issue #509 — Implement Soroban Asset Verification Service + */ + +export interface SorobanAssetMetadata { + /** Asset code (e.g. "USDC") */ + code: string; + /** Human-readable name */ + name?: string; + /** Decimal precision */ + decimals?: number; + /** Optional homepage / domain */ + domain?: string; + /** Arbitrary extra fields */ + [key: string]: unknown; +} + +export interface SorobanAsset { + /** Unique on-chain asset identifier (contract address or classic asset string) */ + id: string; + /** Issuer Stellar account ID (G…) */ + issuerId: string; + metadata: SorobanAssetMetadata; +} + +export type VerificationStatus = 'verified' | 'unverified' | 'invalid'; + +export interface AssetVerificationResult { + assetId: string; + status: VerificationStatus; + isVerified: boolean; + issuerValid: boolean; + metadataValid: boolean; + reason?: string; + verifiedAt: number; +} + +export interface AssetVerifierConfig { + /** Minimum issuer account ID length check. Default 56 (standard Stellar). */ + minIssuerLength?: number; + /** Injected clock for deterministic testing. Defaults to Date.now. */ + now?: () => number; +} diff --git a/src/verification/assets/stellar/index.ts b/src/verification/assets/stellar/index.ts index ab28e6cd..2abbc60d 100644 --- a/src/verification/assets/stellar/index.ts +++ b/src/verification/assets/stellar/index.ts @@ -1 +1,2 @@ export * from './asset-verifier.service'; +export * from './asset-verifier.types';