diff --git a/backend/config/sorobanNodeRegistry.ts b/backend/config/sorobanNodeRegistry.ts new file mode 100644 index 00000000..3f401d8e --- /dev/null +++ b/backend/config/sorobanNodeRegistry.ts @@ -0,0 +1,92 @@ +/** + * Soroban RPC node registry — endpoints and metadata for decentralized selection. + * Issue #612 + */ + +export type SorobanNetwork = 'testnet' | 'mainnet' | 'futurenet'; + +export interface SorobanNodeConfig { + /** Unique node identifier */ + id: string; + /** Human-readable label */ + name: string; + /** RPC endpoint URL */ + endpoint: string; + /** Stellar/Soroban network */ + network: SorobanNetwork; + /** Optional priority hint (lower = preferred when scores tie) */ + priority?: number; + /** Arbitrary metadata (region, provider, etc.) */ + metadata?: Record; +} + +/** Default Soroban RPC nodes for testnet and mainnet */ +export const DEFAULT_SOROBAN_NODES: SorobanNodeConfig[] = [ + { + id: 'soroban-testnet-primary', + name: 'Stellar Testnet RPC (Primary)', + endpoint: 'https://soroban-testnet.stellar.org', + network: 'testnet', + priority: 1, + metadata: { provider: 'stellar', region: 'us-east' }, + }, + { + id: 'soroban-testnet-secondary', + name: 'Stellar Testnet RPC (Secondary)', + endpoint: 'https://soroban-testnet-alt.stellar.org', + network: 'testnet', + priority: 2, + metadata: { provider: 'stellar', region: 'eu-west' }, + }, + { + id: 'soroban-mainnet-primary', + name: 'Stellar Mainnet RPC (Primary)', + endpoint: 'https://soroban-mainnet.stellar.org', + network: 'mainnet', + priority: 1, + metadata: { provider: 'stellar', region: 'us-east' }, + }, + { + id: 'soroban-mainnet-secondary', + name: 'Stellar Mainnet RPC (Secondary)', + endpoint: 'https://soroban-mainnet-alt.stellar.org', + network: 'mainnet', + priority: 2, + metadata: { provider: 'stellar', region: 'ap-southeast' }, + }, +]; + +export class SorobanNodeRegistry { + private nodes = new Map(); + + constructor(initialNodes: SorobanNodeConfig[] = DEFAULT_SOROBAN_NODES) { + for (const node of initialNodes) { + this.register(node); + } + } + + register(node: SorobanNodeConfig): void { + if (!node.id || !node.endpoint) { + throw new Error('Node id and endpoint are required'); + } + this.nodes.set(node.id, { ...node }); + } + + unregister(nodeId: string): boolean { + return this.nodes.delete(nodeId); + } + + get(nodeId: string): SorobanNodeConfig | undefined { + return this.nodes.get(nodeId); + } + + getAll(): SorobanNodeConfig[] { + return [...this.nodes.values()]; + } + + getByNetwork(network: SorobanNetwork): SorobanNodeConfig[] { + return this.getAll().filter((n) => n.network === network); + } +} + +export const sorobanNodeRegistry = new SorobanNodeRegistry(); diff --git a/backend/monitoring/__tests__/nodeReputationMetrics.test.ts b/backend/monitoring/__tests__/nodeReputationMetrics.test.ts new file mode 100644 index 00000000..88d5f135 --- /dev/null +++ b/backend/monitoring/__tests__/nodeReputationMetrics.test.ts @@ -0,0 +1,64 @@ +/** + * Tests for node reputation monitoring metrics — Issue #612 + */ + +import { NodeReputationMetrics } from '../nodeReputationMetrics'; +import { NodeReputationService } from '../../shared/soroban/NodeReputationService'; + +describe('NodeReputationMetrics', () => { + let reputation: NodeReputationService; + let metrics: NodeReputationMetrics; + let now: number; + + beforeEach(() => { + now = 3_000_000; + reputation = new NodeReputationService(undefined, undefined, undefined, () => now); + metrics = new NodeReputationMetrics(reputation); + }); + + afterEach(() => { + reputation.destroy(); + }); + + it('collects per-node metrics including latency percentiles and score', () => { + reputation.registerNode('node-1'); + for (let i = 0; i < 5; i++) { + reputation.recordOutcome({ + nodeId: 'node-1', + success: true, + responseTimeMs: 100 + i * 10, + blockHeight: 5000, + timestamp: now + i, + }); + } + + const snapshot = metrics.collect(); + expect(snapshot.totalNodes).toBe(1); + expect(snapshot.aliveNodes).toBe(1); + expect(snapshot.avgScore).toBeGreaterThan(0); + + const names = snapshot.entries.map((e) => e.name); + expect(names).toContain('node_latency_p50_ms'); + expect(names).toContain('node_latency_p95_ms'); + expect(names).toContain('node_latency_p99_ms'); + expect(names).toContain('node_success_rate'); + expect(names).toContain('node_reputation_score'); + expect(names).toContain('node_last_block_height'); + expect(names).toContain('node_liveness'); + }); + + it('reports circuit breaker state', () => { + reputation.registerNode('dead-node'); + for (let i = 0; i < 5; i++) { + reputation.recordOutcome({ + nodeId: 'dead-node', + success: false, + responseTimeMs: 100, + timestamp: now + i, + }); + } + const snapshot = metrics.collect(); + expect(snapshot.circuitBreakerOpen).toBe(true); + expect(snapshot.deadNodes).toBe(1); + }); +}); diff --git a/backend/monitoring/nodeReputationMetrics.ts b/backend/monitoring/nodeReputationMetrics.ts new file mode 100644 index 00000000..23ee2145 --- /dev/null +++ b/backend/monitoring/nodeReputationMetrics.ts @@ -0,0 +1,81 @@ +/** + * Node reputation and health metrics for monitoring dashboards. + * Issue #612 + */ + +import type { NodeReputationService } from '../shared/soroban/NodeReputationService'; +import type { NodeMetrics, NodeReputationScore } from '../shared/soroban/types'; + +export interface NodeMetricEntry { + name: string; + value: number; + nodeId: string; + timestamp: number; +} + +export interface NodeReputationMetricsSnapshot { + entries: NodeMetricEntry[]; + totalNodes: number; + aliveNodes: number; + deadNodes: number; + avgScore: number; + circuitBreakerOpen: boolean; +} + +export class NodeReputationMetrics { + constructor(private readonly reputation: NodeReputationService) {} + + collect(): NodeReputationMetricsSnapshot { + const dashboard = this.reputation.getDashboard(); + const now = Date.now(); + const entries: NodeMetricEntry[] = []; + + for (const { nodeId, metrics, score } of dashboard.nodes) { + entries.push( + this.entry('node_success_rate', metrics.successRate, nodeId, now), + this.entry('node_latency_p50_ms', metrics.latency.p50, nodeId, now), + this.entry('node_latency_p95_ms', metrics.latency.p95, nodeId, now), + this.entry('node_latency_p99_ms', metrics.latency.p99, nodeId, now), + this.entry('node_last_block_height', metrics.lastBlockHeight, nodeId, now), + this.entry('node_liveness', metrics.isLive ? 1 : 0, nodeId, now), + this.entry('node_reputation_score', score.score, nodeId, now), + this.entry('node_consecutive_failures', metrics.consecutiveFailures, nodeId, now), + this.entry('node_is_dead', metrics.isDead ? 1 : 0, nodeId, now), + ); + } + + const scores = dashboard.nodes.map((n) => n.score.score); + const avgScore = + scores.length > 0 ? scores.reduce((a, b) => a + b, 0) / scores.length : 0; + + return { + entries, + totalNodes: dashboard.nodes.length, + aliveNodes: dashboard.aliveCount, + deadNodes: dashboard.deadCount, + avgScore, + circuitBreakerOpen: dashboard.circuitBreaker.open, + }; + } + + getNodeMetrics(nodeId: string): NodeMetrics | undefined { + return this.reputation.getMetrics(nodeId); + } + + getNodeScore(nodeId: string): NodeReputationScore { + return this.reputation.getScore(nodeId); + } + + private entry( + name: string, + value: number, + nodeId: string, + timestamp: number, + ): NodeMetricEntry { + return { name, value, nodeId, timestamp }; + } +} + +import { nodeReputationService } from '../shared/soroban/NodeReputationService'; + +export const nodeReputationMetrics = new NodeReputationMetrics(nodeReputationService); diff --git a/backend/services/index.ts b/backend/services/index.ts index a11eec92..a677bea3 100644 --- a/backend/services/index.ts +++ b/backend/services/index.ts @@ -328,3 +328,42 @@ export type { // ── DI Container ────────────────────────────────────────────────────────────── export { container, Container } from './container'; + +// ── Soroban Node Reputation (#612) ─────────────────────────────────────────── +export { + SorobanNodeRegistry, + sorobanNodeRegistry, + DEFAULT_SOROBAN_NODES, +} from '../config/sorobanNodeRegistry'; +export type { SorobanNodeConfig, SorobanNetwork } from '../config/sorobanNodeRegistry'; +export { NodeScoreCache } from '../shared/cache/nodeScoreCache'; +export type { NodeScoreRecord, NodeScoreCacheConfig } from '../shared/cache/nodeScoreCache'; +export { + NodeReputationService, + nodeReputationService, + NodeSelector, + nodeSelector, + REPUTATION_WEIGHTS, + REPUTATION_THRESHOLDS, +} from '../shared/soroban'; +export type { + LivenessProvider, + OpsAlertDispatcher, + RandomSource, + LatencyPercentiles, + NodeMetrics, + NodeReputationScore, + NodeSelectionResult, + CircuitBreakerState, + RpcRequestOutcome, + NodeHealthSnapshot, + ReputationDashboardSnapshot, +} from '../shared/soroban'; +export { + NodeReputationMetrics, + nodeReputationMetrics, +} from '../monitoring/nodeReputationMetrics'; +export type { + NodeMetricEntry, + NodeReputationMetricsSnapshot, +} from '../monitoring/nodeReputationMetrics'; diff --git a/backend/shared/cache/__tests__/nodeScoreCache.test.ts b/backend/shared/cache/__tests__/nodeScoreCache.test.ts new file mode 100644 index 00000000..b2270f59 --- /dev/null +++ b/backend/shared/cache/__tests__/nodeScoreCache.test.ts @@ -0,0 +1,119 @@ +/** + * Tests for NodeScoreCache — Issue #612 + */ + +import { NodeScoreCache } from '../nodeScoreCache'; +import type { RedisClient } from '../../../services/subscriptionCacheService'; + +class FakeRedis implements RedisClient { + private store = new Map(); + + async get(key: string): Promise { + const entry = this.store.get(key); + if (!entry) return null; + if (Date.now() > entry.expiresAt) { + this.store.delete(key); + return null; + } + return entry.value; + } + + async set(key: string, value: string, _mode: 'EX', ttlSeconds: number): Promise<'OK'> { + this.store.set(key, { value, expiresAt: Date.now() + ttlSeconds * 1000 }); + return 'OK'; + } + + async del(...keys: string[]): Promise { + let n = 0; + for (const k of keys) { + if (this.store.delete(k)) n++; + } + return n; + } + + async keys(pattern: string): Promise { + const prefix = pattern.replace(/\*$/, ''); + return [...this.store.keys()].filter((k) => k.startsWith(prefix)); + } + + async ping(): Promise { + return 'PONG'; + } + + async quit(): Promise<'OK'> { + return 'OK'; + } +} + +describe('NodeScoreCache', () => { + it('saves and retrieves node scores', async () => { + const cache = new NodeScoreCache(new FakeRedis()); + await cache.save({ + nodeId: 'node-1', + score: 0.85, + successRate: 0.9, + inverseLatency: 0.8, + freshness: 0.95, + liveness: 1, + updatedAt: Date.now(), + }); + const record = await cache.get('node-1'); + expect(record!.score).toBe(0.85); + expect(record!.successRate).toBe(0.9); + }); + + it('uses 5-minute default TTL', async () => { + const cache = new NodeScoreCache(new FakeRedis()); + expect(cache.getMetrics().writes).toBe(0); + await cache.save({ + nodeId: 'node-2', + score: 0.5, + successRate: 0.5, + inverseLatency: 0.5, + freshness: 0.5, + liveness: 0.5, + updatedAt: Date.now(), + }); + expect(cache.getMetrics().writes).toBe(1); + }); + + it('retrieves multiple scores via getAll', async () => { + const cache = new NodeScoreCache(new FakeRedis()); + await cache.save({ + nodeId: 'a', + score: 0.7, + successRate: 0.7, + inverseLatency: 0.7, + freshness: 0.7, + liveness: 0.7, + updatedAt: Date.now(), + }); + await cache.save({ + nodeId: 'b', + score: 0.8, + successRate: 0.8, + inverseLatency: 0.8, + freshness: 0.8, + liveness: 0.8, + updatedAt: Date.now(), + }); + const all = await cache.getAll(['a', 'b', 'missing']); + expect(all.size).toBe(2); + expect(all.get('a')!.score).toBe(0.7); + }); + + it('invalidates cached scores', async () => { + const cache = new NodeScoreCache(new FakeRedis()); + await cache.save({ + nodeId: 'node-x', + score: 0.6, + successRate: 0.6, + inverseLatency: 0.6, + freshness: 0.6, + liveness: 0.6, + updatedAt: Date.now(), + }); + await cache.invalidate('node-x'); + expect(await cache.get('node-x')).toBeNull(); + }); +}); diff --git a/backend/shared/cache/nodeScoreCache.ts b/backend/shared/cache/nodeScoreCache.ts new file mode 100644 index 00000000..91a9692b --- /dev/null +++ b/backend/shared/cache/nodeScoreCache.ts @@ -0,0 +1,92 @@ +/** + * Redis-backed persistence for Soroban node reputation scores. + * Issue #612 — 5-minute TTL per node score. + */ + +import type { RedisClient } from '../../services/subscriptionCacheService'; + +export interface NodeScoreRecord { + nodeId: string; + score: number; + successRate: number; + inverseLatency: number; + freshness: number; + liveness: number; + updatedAt: number; +} + +export interface NodeScoreCacheConfig { + /** TTL for score entries in seconds. Default: 300 (5 min). */ + ttlSeconds?: number; + /** Redis key prefix. Default: 'subtrackr:soroban:score:'. */ + keyPrefix?: string; +} + +const DEFAULTS = { + ttlSeconds: 300, + keyPrefix: 'subtrackr:soroban:score:', +} as const; + +export class NodeScoreCache { + private readonly ttl: number; + private readonly prefix: string; + private writes = 0; + private reads = 0; + private errors = 0; + + constructor( + private readonly redis: RedisClient, + config: NodeScoreCacheConfig = {}, + ) { + this.ttl = config.ttlSeconds ?? DEFAULTS.ttlSeconds; + this.prefix = config.keyPrefix ?? DEFAULTS.keyPrefix; + } + + private key(nodeId: string): string { + return `${this.prefix}${nodeId}`; + } + + async save(record: NodeScoreRecord): Promise { + try { + await this.redis.set(this.key(record.nodeId), JSON.stringify(record), 'EX', this.ttl); + this.writes++; + } catch { + this.errors++; + } + } + + async get(nodeId: string): Promise { + try { + const raw = await this.redis.get(this.key(nodeId)); + this.reads++; + if (!raw) return null; + return JSON.parse(raw) as NodeScoreRecord; + } catch { + this.errors++; + return null; + } + } + + async getAll(nodeIds: string[]): Promise> { + const result = new Map(); + await Promise.all( + nodeIds.map(async (id) => { + const record = await this.get(id); + if (record) result.set(id, record); + }), + ); + return result; + } + + async invalidate(nodeId: string): Promise { + try { + await this.redis.del(this.key(nodeId)); + } catch { + this.errors++; + } + } + + getMetrics(): { writes: number; reads: number; errors: number } { + return { writes: this.writes, reads: this.reads, errors: this.errors }; + } +} diff --git a/backend/shared/soroban/NodeReputationService.ts b/backend/shared/soroban/NodeReputationService.ts new file mode 100644 index 00000000..d22e7b80 --- /dev/null +++ b/backend/shared/soroban/NodeReputationService.ts @@ -0,0 +1,433 @@ +/** + * NodeReputationService — tracks RPC node health metrics and computes reputation scores. + * Issue #612 + * + * Score formula: 40% success rate + 30% inverse latency + 20% freshness + 10% liveness + */ + +import type { SorobanNodeConfig } from '../../config/sorobanNodeRegistry'; +import type { NodeScoreCache } from '../cache/nodeScoreCache'; +import type { + CircuitBreakerState, + NodeMetrics, + NodeReputationScore, + ReputationDashboardSnapshot, + RpcRequestOutcome, +} from './types'; +import { + REPUTATION_THRESHOLDS, + REPUTATION_WEIGHTS, +} from './types'; + +export interface LivenessProvider { + ping(node: SorobanNodeConfig): Promise<{ alive: boolean; blockHeight?: number }>; +} + +export interface OpsAlertDispatcher { + alert(title: string, message: string): void; +} + +interface OutcomeRecord { + success: boolean; + responseTimeMs: number; + blockHeight?: number; + timestamp: number; +} + +function percentile(sorted: number[], p: number): number { + if (sorted.length === 0) return 0; + const idx = Math.ceil((p / 100) * sorted.length) - 1; + return sorted[Math.max(0, idx)]; +} + +function createDefaultMetrics(nodeId: string): NodeMetrics { + return { + nodeId, + successRate: 1, + latency: { p50: 0, p95: 0, p99: 0 }, + lastBlockHeight: 0, + isLive: true, + lastPingAt: 0, + consecutiveFailures: 0, + isDead: false, + }; +} + +export class NodeReputationService { + private metrics = new Map(); + private outcomes = new Map(); + private scores = new Map(); + private circuitBreaker: CircuitBreakerState = { open: false, alertSent: false }; + private livenessTimer?: ReturnType; + private circuitRetryTimer?: ReturnType; + private nowFn: () => number; + + constructor( + private readonly scoreCache?: NodeScoreCache, + private readonly livenessProvider?: LivenessProvider, + private readonly opsAlert?: OpsAlertDispatcher, + nowFn: () => number = Date.now, + ) { + this.nowFn = nowFn; + } + + // ── Node registration ───────────────────────────────────────────────────── + + registerNode(nodeId: string): void { + if (!this.metrics.has(nodeId)) { + this.metrics.set(nodeId, createDefaultMetrics(nodeId)); + this.outcomes.set(nodeId, []); + this.scores.set(nodeId, this.buildNeutralScore(nodeId)); + } + } + + registerNodes(nodes: SorobanNodeConfig[]): void { + for (const node of nodes) { + this.registerNode(node.id); + } + } + + // ── Request outcome recording ─────────────────────────────────────────────── + + recordOutcome(outcome: RpcRequestOutcome): void { + this.registerNode(outcome.nodeId); + const records = this.outcomes.get(outcome.nodeId)!; + records.push({ + success: outcome.success, + responseTimeMs: outcome.responseTimeMs, + blockHeight: outcome.blockHeight, + timestamp: outcome.timestamp, + }); + this.pruneOutcomes(outcome.nodeId); + this.recomputeMetrics(outcome.nodeId); + void this.recomputeScore(outcome.nodeId); + } + + // ── Liveness monitoring ───────────────────────────────────────────────────── + + startLivenessChecks(nodes: SorobanNodeConfig[]): void { + this.stopLivenessChecks(); + for (const node of nodes) { + this.registerNode(node.id); + } + this.livenessTimer = setInterval(() => { + void this.runLivenessChecks(nodes); + }, REPUTATION_THRESHOLDS.livenessPingIntervalMs); + } + + stopLivenessChecks(): void { + if (this.livenessTimer) { + clearInterval(this.livenessTimer); + this.livenessTimer = undefined; + } + } + + async runLivenessChecks(nodes: SorobanNodeConfig[]): Promise { + if (!this.livenessProvider) return; + const now = this.nowFn(); + await Promise.all( + nodes.map(async (node) => { + this.registerNode(node.id); + const m = this.metrics.get(node.id)!; + if (m.isDead) return; + + try { + const result = await this.livenessProvider!.ping(node); + m.isLive = result.alive; + m.lastPingAt = now; + if (result.blockHeight !== undefined) { + m.lastBlockHeight = result.blockHeight; + } + } catch { + m.isLive = false; + m.lastPingAt = now; + } + await this.recomputeScore(node.id); + }), + ); + this.updateCircuitBreaker(); + } + + // ── Dead node management ──────────────────────────────────────────────────── + + async retryDeadNodeHealthCheck( + nodeId: string, + node: SorobanNodeConfig, + ): Promise { + const m = this.metrics.get(nodeId); + if (!m || !m.isDead) return false; + + const now = this.nowFn(); + if ( + m.lastHealthCheckAt && + now - m.lastHealthCheckAt < REPUTATION_THRESHOLDS.deadNodeHealthCheckMs + ) { + return false; + } + m.lastHealthCheckAt = now; + + if (!this.livenessProvider) return false; + + try { + const result = await this.livenessProvider.ping(node); + if (result.alive) { + m.isDead = false; + m.deadSince = undefined; + m.consecutiveFailures = 0; + m.isLive = true; + m.lastPingAt = now; + if (result.blockHeight !== undefined) { + m.lastBlockHeight = result.blockHeight; + } + await this.recomputeScore(nodeId); + this.updateCircuitBreaker(); + return true; + } + } catch { + // remain dead + } + return false; + } + + // ── Score access ──────────────────────────────────────────────────────────── + + getScore(nodeId: string): NodeReputationScore { + return ( + this.scores.get(nodeId) ?? this.buildNeutralScore(nodeId) + ); + } + + getAllScores(): NodeReputationScore[] { + return [...this.scores.values()]; + } + + getMetrics(nodeId: string): NodeMetrics | undefined { + return this.metrics.get(nodeId); + } + + getAliveNodeIds(): string[] { + return [...this.metrics.entries()] + .filter(([, m]) => !m.isDead) + .map(([id]) => id); + } + + getCircuitBreaker(): CircuitBreakerState { + return { ...this.circuitBreaker }; + } + + isCircuitOpen(): boolean { + return this.circuitBreaker.open; + } + + getDashboard(): ReputationDashboardSnapshot { + const nodes: ReputationDashboardSnapshot['nodes'] = []; + for (const [nodeId, metrics] of this.metrics) { + nodes.push({ + nodeId, + metrics: { ...metrics, latency: { ...metrics.latency } }, + score: this.getScore(nodeId), + }); + } + const aliveCount = nodes.filter((n) => !n.metrics.isDead).length; + return { + nodes, + circuitBreaker: this.getCircuitBreaker(), + aliveCount, + deadCount: nodes.length - aliveCount, + }; + } + + async loadScoresFromCache(nodeIds: string[]): Promise { + if (!this.scoreCache) return; + const cached = await this.scoreCache.getAll(nodeIds); + for (const [nodeId, record] of cached) { + this.registerNode(nodeId); + this.scores.set(nodeId, { + nodeId, + score: record.score, + successRateComponent: record.successRate * REPUTATION_WEIGHTS.successRate, + inverseLatencyComponent: record.inverseLatency * REPUTATION_WEIGHTS.inverseLatency, + freshnessComponent: record.freshness * REPUTATION_WEIGHTS.freshness, + livenessComponent: record.liveness * REPUTATION_WEIGHTS.liveness, + computedAt: record.updatedAt, + }); + } + } + + destroy(): void { + this.stopLivenessChecks(); + if (this.circuitRetryTimer) { + clearInterval(this.circuitRetryTimer); + this.circuitRetryTimer = undefined; + } + } + + // ── Internal ──────────────────────────────────────────────────────────────── + + private pruneOutcomes(nodeId: string): void { + const cutoff = this.nowFn() - REPUTATION_THRESHOLDS.successRateWindowMs; + const records = this.outcomes.get(nodeId)!; + this.outcomes.set( + nodeId, + records.filter((r) => r.timestamp >= cutoff), + ); + } + + private recomputeMetrics(nodeId: string): void { + const m = this.metrics.get(nodeId)!; + const records = this.outcomes.get(nodeId) ?? []; + const now = this.nowFn(); + + if (records.length > 0) { + const successes = records.filter((r) => r.success).length; + m.successRate = successes / records.length; + + const latencies = records + .filter((r) => r.success) + .map((r) => r.responseTimeMs) + .sort((a, b) => a - b); + m.latency = { + p50: percentile(latencies, 50), + p95: percentile(latencies, 95), + p99: percentile(latencies, 99), + }; + + const heights = records + .filter((r) => r.blockHeight !== undefined) + .map((r) => r.blockHeight!); + if (heights.length > 0) { + m.lastBlockHeight = Math.max(...heights); + } + + const last = records[records.length - 1]; + if (last.success) { + m.consecutiveFailures = 0; + } else { + m.consecutiveFailures++; + } + } + + if (m.consecutiveFailures >= REPUTATION_THRESHOLDS.deadNodeFailureCount && !m.isDead) { + m.isDead = true; + m.deadSince = now; + m.isLive = false; + } + + this.updateCircuitBreaker(); + } + + private async recomputeScore(nodeId: string): Promise { + const m = this.metrics.get(nodeId); + if (!m) return; + + const maxBlockHeight = this.getMaxBlockHeight(); + const successNorm = m.successRate; + + const maxLatency = this.getMaxP95Latency() || 1; + const latencyNorm = m.latency.p95 > 0 ? 1 - Math.min(1, m.latency.p95 / maxLatency) : 1; + + const freshnessNorm = + maxBlockHeight > 0 && m.lastBlockHeight > 0 + ? Math.min(1, m.lastBlockHeight / maxBlockHeight) + : REPUTATION_THRESHOLDS.neutralScore; + + const livenessNorm = m.isLive ? 1 : 0; + + const successRateComponent = successNorm * REPUTATION_WEIGHTS.successRate; + const inverseLatencyComponent = latencyNorm * REPUTATION_WEIGHTS.inverseLatency; + const freshnessComponent = freshnessNorm * REPUTATION_WEIGHTS.freshness; + const livenessComponent = livenessNorm * REPUTATION_WEIGHTS.liveness; + + const score: NodeReputationScore = { + nodeId, + score: + successRateComponent + + inverseLatencyComponent + + freshnessComponent + + livenessComponent, + successRateComponent, + inverseLatencyComponent, + freshnessComponent, + livenessComponent, + computedAt: this.nowFn(), + }; + + this.scores.set(nodeId, score); + + if (this.scoreCache) { + await this.scoreCache.save({ + nodeId, + score: score.score, + successRate: successNorm, + inverseLatency: latencyNorm, + freshness: freshnessNorm, + liveness: livenessNorm, + updatedAt: score.computedAt, + }); + } + } + + private buildNeutralScore(nodeId: string): NodeReputationScore { + const n = REPUTATION_THRESHOLDS.neutralScore; + return { + nodeId, + score: n, + successRateComponent: n * REPUTATION_WEIGHTS.successRate, + inverseLatencyComponent: n * REPUTATION_WEIGHTS.inverseLatency, + freshnessComponent: n * REPUTATION_WEIGHTS.freshness, + livenessComponent: n * REPUTATION_WEIGHTS.liveness, + computedAt: this.nowFn(), + }; + } + + private getMaxBlockHeight(): number { + let max = 0; + for (const m of this.metrics.values()) { + if (m.lastBlockHeight > max) max = m.lastBlockHeight; + } + return max; + } + + private getMaxP95Latency(): number { + let max = 0; + for (const m of this.metrics.values()) { + if (m.latency.p95 > max) max = m.latency.p95; + } + return max; + } + + private updateCircuitBreaker(): void { + const alive = this.getAliveNodeIds(); + const now = this.nowFn(); + + if (alive.length === 0 && this.metrics.size > 0) { + if (!this.circuitBreaker.open) { + this.circuitBreaker = { open: true, openedAt: now, alertSent: false, lastRetryAt: now }; + } + if (!this.circuitBreaker.alertSent && this.opsAlert) { + this.opsAlert.alert( + 'Soroban RPC Circuit Breaker Open', + 'All Soroban RPC nodes are marked dead. Transaction routing paused.', + ); + this.circuitBreaker.alertSent = true; + } + this.scheduleCircuitRetry(); + } else if (alive.length > 0 && this.circuitBreaker.open) { + this.circuitBreaker = { open: false, alertSent: false }; + if (this.circuitRetryTimer) { + clearInterval(this.circuitRetryTimer); + this.circuitRetryTimer = undefined; + } + } + } + + private scheduleCircuitRetry(): void { + if (this.circuitRetryTimer) return; + this.circuitRetryTimer = setInterval(() => { + this.circuitBreaker.lastRetryAt = this.nowFn(); + // Dead node health checks are driven externally via retryDeadNodeHealthCheck + }, REPUTATION_THRESHOLDS.circuitBreakerRetryMs); + } +} + +export const nodeReputationService = new NodeReputationService(); diff --git a/backend/shared/soroban/NodeSelector.ts b/backend/shared/soroban/NodeSelector.ts new file mode 100644 index 00000000..88089af3 --- /dev/null +++ b/backend/shared/soroban/NodeSelector.ts @@ -0,0 +1,140 @@ +/** + * NodeSelector — weighted random RPC node selection with fallback chains. + * Issue #612 + */ + +import type { SorobanNodeConfig } from '../../config/sorobanNodeRegistry'; +import { nodeReputationService, type NodeReputationService } from './NodeReputationService'; +import type { NodeSelectionResult, NodeReputationScore } from './types'; + +export interface RandomSource { + /** Returns a float in [0, 1) */ + next(): number; +} + +const defaultRandom: RandomSource = { + next: () => Math.random(), +}; + +export class NodeSelector { + constructor( + private readonly reputation: NodeReputationService, + private readonly random: RandomSource = defaultRandom, + ) {} + + /** + * Select a node using weighted random selection. + * Higher reputation score = higher probability of selection. + */ + selectWeightedRandom(candidates: SorobanNodeConfig[]): SorobanNodeConfig | null { + const alive = this.filterAlive(candidates); + if (alive.length === 0) return null; + + const scores = alive.map((node) => ({ + node, + score: this.reputation.getScore(node.id).score, + })); + + const totalWeight = scores.reduce((sum, s) => sum + s.score, 0); + if (totalWeight <= 0) { + return alive[Math.floor(this.random.next() * alive.length)]; + } + + let roll = this.random.next() * totalWeight; + for (const entry of scores) { + roll -= entry.score; + if (roll <= 0) return entry.node; + } + return scores[scores.length - 1].node; + } + + /** + * Build a fallback chain: primary (highest score) → secondary → tertiary. + */ + getFallbackChain(candidates: SorobanNodeConfig[]): NodeSelectionResult | null { + const alive = this.filterAlive(candidates); + if (alive.length === 0) return null; + + const ranked = this.rankByScore(alive); + return { + primary: ranked[0].nodeId, + secondary: ranked[1]?.nodeId ?? null, + tertiary: ranked[2]?.nodeId ?? null, + }; + } + + /** + * Select the highest-scored alive node (deterministic primary). + */ + selectPrimary(candidates: SorobanNodeConfig[]): SorobanNodeConfig | null { + const alive = this.filterAlive(candidates); + if (alive.length === 0) return null; + const ranked = this.rankByScore(alive); + const top = ranked[0]; + return alive.find((n) => n.id === top.nodeId) ?? null; + } + + /** + * Execute a transaction attempt through the fallback chain. + * Tries primary → secondary → tertiary until one succeeds or all fail. + */ + async executeWithFallback( + candidates: SorobanNodeConfig[], + executor: (node: SorobanNodeConfig) => Promise, + ): Promise<{ result: T; nodeId: string }> { + if (this.reputation.isCircuitOpen()) { + throw new Error('Soroban RPC circuit breaker is open — all nodes dead'); + } + + const chain = this.getFallbackChain(candidates); + if (!chain) { + throw new Error('No alive Soroban RPC nodes available'); + } + + const orderedIds = [chain.primary, chain.secondary, chain.tertiary].filter( + (id): id is string => id !== null, + ); + + let lastError: Error | undefined; + for (const nodeId of orderedIds) { + const node = candidates.find((n) => n.id === nodeId); + if (!node) continue; + const start = Date.now(); + try { + const result = await executor(node); + this.reputation.recordOutcome({ + nodeId, + success: true, + responseTimeMs: Date.now() - start, + timestamp: Date.now(), + }); + return { result, nodeId }; + } catch (err) { + lastError = err instanceof Error ? err : new Error(String(err)); + this.reputation.recordOutcome({ + nodeId, + success: false, + responseTimeMs: Date.now() - start, + timestamp: Date.now(), + }); + } + } + + throw lastError ?? new Error('All fallback nodes failed'); + } + + private filterAlive(candidates: SorobanNodeConfig[]): SorobanNodeConfig[] { + return candidates.filter((node) => { + const metrics = this.reputation.getMetrics(node.id); + return !metrics?.isDead; + }); + } + + private rankByScore(candidates: SorobanNodeConfig[]): NodeReputationScore[] { + return candidates + .map((node) => this.reputation.getScore(node.id)) + .sort((a, b) => b.score - a.score); + } +} + +export const nodeSelector = new NodeSelector(nodeReputationService); diff --git a/backend/shared/soroban/__tests__/NodeReputationService.test.ts b/backend/shared/soroban/__tests__/NodeReputationService.test.ts new file mode 100644 index 00000000..ab332164 --- /dev/null +++ b/backend/shared/soroban/__tests__/NodeReputationService.test.ts @@ -0,0 +1,204 @@ +/** + * Tests for NodeReputationService — Issue #612 + */ + +import { NodeReputationService } from '../NodeReputationService'; +import { NodeScoreCache } from '../../cache/nodeScoreCache'; +import type { RedisClient } from '../../../services/subscriptionCacheService'; +import { REPUTATION_THRESHOLDS, REPUTATION_WEIGHTS } from '../types'; + +class FakeRedis implements RedisClient { + private store = new Map(); + + async get(key: string): Promise { + return this.store.get(key) ?? null; + } + + async set(key: string, value: string, _mode: 'EX', _ttl: number): Promise<'OK'> { + this.store.set(key, value); + return 'OK'; + } + + async del(...keys: string[]): Promise { + let n = 0; + for (const k of keys) { + if (this.store.delete(k)) n++; + } + return n; + } + + async keys(pattern: string): Promise { + const prefix = pattern.replace(/\*$/, ''); + return [...this.store.keys()].filter((k) => k.startsWith(prefix)); + } + + async ping(): Promise { + return 'PONG'; + } + + async quit(): Promise<'OK'> { + return 'OK'; + } +} + +describe('NodeReputationService', () => { + let svc: NodeReputationService; + let now: number; + + beforeEach(() => { + now = 1_000_000; + svc = new NodeReputationService(undefined, undefined, undefined, () => now); + }); + + afterEach(() => { + svc.destroy(); + }); + + it('assigns neutral score to newly registered nodes', () => { + svc.registerNode('node-a'); + const score = svc.getScore('node-a'); + expect(score.score).toBe(REPUTATION_THRESHOLDS.neutralScore); + }); + + it('tracks success rate over rolling 24h window', () => { + svc.registerNode('node-a'); + for (let i = 0; i < 8; i++) { + svc.recordOutcome({ + nodeId: 'node-a', + success: i < 6, + responseTimeMs: 100, + timestamp: now + i * 1000, + }); + } + const metrics = svc.getMetrics('node-a')!; + expect(metrics.successRate).toBeCloseTo(0.75); + }); + + it('computes latency percentiles p50, p95, p99', () => { + svc.registerNode('node-a'); + const latencies = [50, 100, 150, 200, 250, 300, 350, 400, 450, 500]; + for (const ms of latencies) { + svc.recordOutcome({ + nodeId: 'node-a', + success: true, + responseTimeMs: ms, + timestamp: now, + }); + } + const { latency } = svc.getMetrics('node-a')!; + expect(latency.p50).toBeGreaterThan(0); + expect(latency.p95).toBeGreaterThanOrEqual(latency.p50); + expect(latency.p99).toBeGreaterThanOrEqual(latency.p95); + }); + + it('applies reputation formula weights correctly', () => { + svc.registerNode('node-a'); + for (let i = 0; i < 10; i++) { + svc.recordOutcome({ + nodeId: 'node-a', + success: true, + responseTimeMs: 100, + blockHeight: 1000, + timestamp: now + i, + }); + } + const score = svc.getScore('node-a'); + const expected = + score.successRateComponent + + score.inverseLatencyComponent + + score.freshnessComponent + + score.livenessComponent; + expect(score.score).toBeCloseTo(expected, 5); + expect(REPUTATION_WEIGHTS.successRate).toBe(0.4); + expect(REPUTATION_WEIGHTS.inverseLatency).toBe(0.3); + expect(REPUTATION_WEIGHTS.freshness).toBe(0.2); + expect(REPUTATION_WEIGHTS.liveness).toBe(0.1); + }); + + it('marks node dead after 5 consecutive failures', () => { + svc.registerNode('node-a'); + for (let i = 0; i < 5; i++) { + svc.recordOutcome({ + nodeId: 'node-a', + success: false, + responseTimeMs: 500, + timestamp: now + i, + }); + } + const metrics = svc.getMetrics('node-a')!; + expect(metrics.isDead).toBe(true); + expect(metrics.consecutiveFailures).toBe(5); + expect(svc.getAliveNodeIds()).not.toContain('node-a'); + }); + + it('opens circuit breaker when all nodes are dead', () => { + const alerts: string[] = []; + const alertSvc = new NodeReputationService( + undefined, + undefined, + { alert: (_t, msg) => alerts.push(msg) }, + () => now, + ); + alertSvc.registerNode('node-a'); + alertSvc.registerNode('node-b'); + for (const id of ['node-a', 'node-b']) { + for (let i = 0; i < 5; i++) { + alertSvc.recordOutcome({ + nodeId: id, + success: false, + responseTimeMs: 100, + timestamp: now + i, + }); + } + } + expect(alertSvc.isCircuitOpen()).toBe(true); + expect(alerts.length).toBeGreaterThan(0); + alertSvc.destroy(); + }); + + it('persists scores to Redis cache with TTL', async () => { + const cache = new NodeScoreCache(new FakeRedis()); + const cachedSvc = new NodeReputationService(cache, undefined, undefined, () => now); + cachedSvc.registerNode('node-a'); + cachedSvc.recordOutcome({ + nodeId: 'node-a', + success: true, + responseTimeMs: 50, + blockHeight: 500, + timestamp: now, + }); + // Allow async cache write + await new Promise((r) => setTimeout(r, 10)); + const record = await cache.get('node-a'); + expect(record).not.toBeNull(); + expect(record!.nodeId).toBe('node-a'); + expect(record!.score).toBeGreaterThan(0); + cachedSvc.destroy(); + }); + + it('tracks last block height from outcomes', () => { + svc.registerNode('node-a'); + svc.recordOutcome({ + nodeId: 'node-a', + success: true, + responseTimeMs: 80, + blockHeight: 12345, + timestamp: now, + }); + expect(svc.getMetrics('node-a')!.lastBlockHeight).toBe(12345); + }); + + it('exposes dashboard snapshot', () => { + svc.registerNode('node-a'); + svc.recordOutcome({ + nodeId: 'node-a', + success: true, + responseTimeMs: 100, + timestamp: now, + }); + const dash = svc.getDashboard(); + expect(dash.nodes).toHaveLength(1); + expect(dash.aliveCount).toBe(1); + expect(dash.deadCount).toBe(0); + }); +}); diff --git a/backend/shared/soroban/__tests__/NodeSelector.test.ts b/backend/shared/soroban/__tests__/NodeSelector.test.ts new file mode 100644 index 00000000..50beb3d9 --- /dev/null +++ b/backend/shared/soroban/__tests__/NodeSelector.test.ts @@ -0,0 +1,129 @@ +/** + * Tests for NodeSelector — Issue #612 + */ + +import { NodeReputationService } from '../NodeReputationService'; +import { NodeSelector } from '../NodeSelector'; +import type { SorobanNodeConfig } from '../../../config/sorobanNodeRegistry'; + +const nodes: SorobanNodeConfig[] = [ + { id: 'node-a', name: 'A', endpoint: 'https://a.example.com', network: 'testnet' }, + { id: 'node-b', name: 'B', endpoint: 'https://b.example.com', network: 'testnet' }, + { id: 'node-c', name: 'C', endpoint: 'https://c.example.com', network: 'testnet' }, +]; + +describe('NodeSelector', () => { + let reputation: NodeReputationService; + let selector: NodeSelector; + let now: number; + + beforeEach(() => { + now = 2_000_000; + reputation = new NodeReputationService(undefined, undefined, undefined, () => now); + for (const node of nodes) { + reputation.registerNode(node.id); + } + selector = new NodeSelector(reputation, { next: () => 0.1 }); + }); + + afterEach(() => { + reputation.destroy(); + }); + + function boostNode(nodeId: string, count: number, latencyMs: number): void { + for (let i = 0; i < count; i++) { + reputation.recordOutcome({ + nodeId, + success: true, + responseTimeMs: latencyMs, + blockHeight: 1000 + i, + timestamp: now + i, + }); + } + } + + it('returns fallback chain ordered by score: primary → secondary → tertiary', () => { + boostNode('node-a', 20, 50); + boostNode('node-b', 10, 100); + boostNode('node-c', 5, 200); + + const chain = selector.getFallbackChain(nodes)!; + expect(chain.primary).toBe('node-a'); + expect(chain.secondary).toBe('node-b'); + expect(chain.tertiary).toBe('node-c'); + }); + + it('selects primary as highest-scored node', () => { + boostNode('node-b', 20, 50); + boostNode('node-a', 5, 200); + const primary = selector.selectPrimary(nodes); + expect(primary!.id).toBe('node-b'); + }); + + it('performs weighted random selection favoring higher scores', () => { + boostNode('node-a', 30, 30); + boostNode('node-b', 3, 300); + // With random=0.1, should pick node-a (highest weight) + const selected = selector.selectWeightedRandom(nodes); + expect(selected!.id).toBe('node-a'); + }); + + it('excludes dead nodes from selection', () => { + for (let i = 0; i < 5; i++) { + reputation.recordOutcome({ + nodeId: 'node-a', + success: false, + responseTimeMs: 100, + timestamp: now + i, + }); + } + const chain = selector.getFallbackChain(nodes)!; + expect(chain.primary).not.toBe('node-a'); + }); + + it('executes through fallback chain until success', async () => { + boostNode('node-c', 10, 50); + const attempts: string[] = []; + + const result = await selector.executeWithFallback(nodes, async (node) => { + attempts.push(node.id); + if (node.id !== 'node-c') throw new Error('fail'); + return 'ok'; + }); + + expect(result.result).toBe('ok'); + expect(result.nodeId).toBe('node-c'); + expect(attempts.length).toBeGreaterThanOrEqual(1); + }); + + it('throws when circuit breaker is open', async () => { + for (const node of nodes) { + for (let i = 0; i < 5; i++) { + reputation.recordOutcome({ + nodeId: node.id, + success: false, + responseTimeMs: 100, + timestamp: now + i, + }); + } + } + await expect( + selector.executeWithFallback(nodes, async () => 'x'), + ).rejects.toThrow(/circuit breaker/i); + }); + + it('returns null when no alive nodes', () => { + for (const node of nodes) { + for (let i = 0; i < 5; i++) { + reputation.recordOutcome({ + nodeId: node.id, + success: false, + responseTimeMs: 100, + timestamp: now + i, + }); + } + } + expect(selector.selectWeightedRandom(nodes)).toBeNull(); + expect(selector.getFallbackChain(nodes)).toBeNull(); + }); +}); diff --git a/backend/shared/soroban/index.ts b/backend/shared/soroban/index.ts new file mode 100644 index 00000000..20f79e2e --- /dev/null +++ b/backend/shared/soroban/index.ts @@ -0,0 +1,8 @@ +export * from './types'; +export { + NodeReputationService, + nodeReputationService, +} from './NodeReputationService'; +export type { LivenessProvider, OpsAlertDispatcher } from './NodeReputationService'; +export { NodeSelector, nodeSelector } from './NodeSelector'; +export type { RandomSource } from './NodeSelector'; diff --git a/backend/shared/soroban/types.ts b/backend/shared/soroban/types.ts new file mode 100644 index 00000000..eec55ead --- /dev/null +++ b/backend/shared/soroban/types.ts @@ -0,0 +1,101 @@ +/** + * Shared types for Soroban node reputation and selection. + * Issue #612 + */ + +export interface LatencyPercentiles { + p50: number; + p95: number; + p99: number; +} + +export interface NodeMetrics { + nodeId: string; + /** Rolling 24h success rate (0–1) */ + successRate: number; + /** Response time percentiles in milliseconds */ + latency: LatencyPercentiles; + /** Last observed ledger/block height from this node */ + lastBlockHeight: number; + /** Whether the node responded to the last liveness ping */ + isLive: boolean; + /** Timestamp of last successful liveness ping */ + lastPingAt: number; + /** Consecutive request failures */ + consecutiveFailures: number; + /** Whether the node is marked dead */ + isDead: boolean; + /** Timestamp when dead status was set */ + deadSince?: number; + /** Timestamp of last health-check retry for dead nodes */ + lastHealthCheckAt?: number; +} + +export interface NodeReputationScore { + nodeId: string; + /** Composite score 0–1 */ + score: number; + successRateComponent: number; + inverseLatencyComponent: number; + freshnessComponent: number; + livenessComponent: number; + computedAt: number; +} + +export interface NodeSelectionResult { + primary: string; + secondary: string | null; + tertiary: string | null; +} + +export interface CircuitBreakerState { + open: boolean; + openedAt?: number; + lastRetryAt?: number; + alertSent: boolean; +} + +export interface RpcRequestOutcome { + nodeId: string; + success: boolean; + responseTimeMs: number; + blockHeight?: number; + timestamp: number; +} + +export interface NodeHealthSnapshot { + nodeId: string; + metrics: NodeMetrics; + score: NodeReputationScore; +} + +export interface ReputationDashboardSnapshot { + nodes: NodeHealthSnapshot[]; + circuitBreaker: CircuitBreakerState; + aliveCount: number; + deadCount: number; +} + +/** Weight constants for reputation formula */ +export const REPUTATION_WEIGHTS = { + successRate: 0.4, + inverseLatency: 0.3, + freshness: 0.2, + liveness: 0.1, +} as const; + +/** Operational thresholds */ +export const REPUTATION_THRESHOLDS = { + /** Consecutive failures before marking a node dead */ + deadNodeFailureCount: 5, + /** Health-check interval for dead nodes (ms) */ + deadNodeHealthCheckMs: 5 * 60 * 1000, + /** Liveness ping interval (ms) */ + livenessPingIntervalMs: 30 * 1000, + /** Circuit breaker retry interval when all nodes dead (ms) */ + circuitBreakerRetryMs: 30 * 1000, + /** Rolling window for success rate (ms) — 24 hours */ + successRateWindowMs: 24 * 60 * 60 * 1000, + /** Neutral score for newly registered nodes (50th percentile) */ + neutralScore: 0.5, +} as const; diff --git a/sandbox/services/usageTrackingService.ts b/sandbox/services/usageTrackingService.ts index ee16da62..dc9cbaa0 100644 --- a/sandbox/services/usageTrackingService.ts +++ b/sandbox/services/usageTrackingService.ts @@ -1,4 +1,4 @@ -import { UsageMetrics, HourlyUsage, DailyUsage } from '../types/sandbox'; +import { UsageMetrics } from '../types/sandbox'; export class UsageTrackingService { private usageData: Map = new Map(); diff --git a/src/screens/DunningDashboard.tsx b/src/screens/DunningDashboard.tsx index b74c51ae..0daee699 100644 --- a/src/screens/DunningDashboard.tsx +++ b/src/screens/DunningDashboard.tsx @@ -20,7 +20,7 @@ import { colors, spacing, typography, borderRadius } from '../utils/constants'; const STAGE_COLOR: Record = { retry: colors.warning, - warn: '#f97316', // orange + warn: '#f97316', // orange suspend: colors.error, cancel: '#6b7280', // gray }; @@ -120,11 +120,10 @@ const EntryCard: React.FC = ({ entry, onPress }) => ( {entry.subscriptionId} - - Subscriber: {entry.subscriberId} - + Subscriber: {entry.subscriberId} - + {STAGE_LABEL[entry.currentStage]} @@ -162,8 +161,14 @@ interface DetailProps { } const DetailSheet: React.FC = ({ entry, onClose }) => { - const { pauseDunning, resumeDunning, overrideStage, escalateToSupport, overrideDunning, recordPaymentAttempt } = - useDunningStore(); + const { + pauseDunning, + resumeDunning, + overrideStage, + escalateToSupport, + overrideDunning, + recordPaymentAttempt, + } = useDunningStore(); const handleEscalate = () => { Alert.alert( @@ -302,16 +307,16 @@ const DetailSheet: React.FC = ({ entry, onClose }) => { : pauseDunning(entry.subscriptionId); onClose(); }}> - - {entry.isPaused ? '▶ Resume' : '⏸ Pause'} - + {entry.isPaused ? '▶ Resume' : '⏸ Pause'} 💳 Manual Payment Override - + 🚨 Escalate to Support @@ -320,7 +325,9 @@ const DetailSheet: React.FC = ({ entry, onClose }) => { handleOverride('resolved')}> - ✅ Mark Resolved + + ✅ Mark Resolved + ()( pauseDunning: (subscriptionId) => { set((s) => ({ entries: s.entries.map((e) => - e.subscriptionId === subscriptionId - ? { ...e, isPaused: true, updatedAt: now() } - : e + e.subscriptionId === subscriptionId ? { ...e, isPaused: true, updatedAt: now() } : e ), })); }, @@ -265,8 +263,7 @@ export const useDunningStore = create()( })); }, - getEntry: (subscriptionId) => - get().entries.find((e) => e.subscriptionId === subscriptionId), + getEntry: (subscriptionId) => get().entries.find((e) => e.subscriptionId === subscriptionId), getActiveEntries: () => get().entries.filter((e) => !e.isPaused), @@ -286,7 +283,8 @@ export const useDunningStore = create()( return { totalActiveDunning: totalActive, stageBreakdown: breakdown, - recoveryRate: totalActive > 0 ? Math.round(((totalActive - totalLost) / totalActive) * 100) : 0, + recoveryRate: + totalActive > 0 ? Math.round(((totalActive - totalLost) / totalActive) * 100) : 0, totalRecovered: 0, totalLost, averageDaysToRecovery: 0,