Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/analytics/utilization/stellar/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './route-utilization.service';
export * from './route-utilization.types';
102 changes: 81 additions & 21 deletions src/analytics/utilization/stellar/route-utilization.service.ts
Original file line number Diff line number Diff line change
@@ -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<string, RouteUtilizationMetrics>();
private readonly usageMap = new Map<string, RouteUsageEntry>();
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 };
}
}
35 changes: 35 additions & 0 deletions src/analytics/utilization/stellar/route-utilization.types.ts
Original file line number Diff line number Diff line change
@@ -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;
}
2 changes: 2 additions & 0 deletions src/discovery/providers/stellar/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './soroban-bridge-discovery.service';
export * from './soroban-bridge-discovery.types';
158 changes: 158 additions & 0 deletions src/discovery/providers/stellar/soroban-bridge-discovery.service.ts
Original file line number Diff line number Diff line change
@@ -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<string, SorobanBridgeProviderMetadata>();
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<BridgeProviderInput[]>,
): Promise<BridgeDiscoveryResult> {
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;
}
}
46 changes: 46 additions & 0 deletions src/discovery/providers/stellar/soroban-bridge-discovery.types.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading