From 26701e2ab3b65bb0df88b84bd482a5e022089cbb Mon Sep 17 00:00:00 2001 From: ubermensh-323 Date: Tue, 20 Oct 2026 21:58:56 +0000 Subject: [PATCH 1/4] fix: #1140 Add performance metrics Closes #1140 --- src/analyticsDashboardExporter.ts | 130 ++++++++++ src/analyticsTracking.ts | 378 +++++++++--------------------- 2 files changed, 240 insertions(+), 268 deletions(-) diff --git a/src/analyticsDashboardExporter.ts b/src/analyticsDashboardExporter.ts index 2bdd83a..a71bf8c 100644 --- a/src/analyticsDashboardExporter.ts +++ b/src/analyticsDashboardExporter.ts @@ -6,6 +6,10 @@ * (start / complete / error). The feature is opt-in: nothing runs unless a * caller explicitly invokes the exporter, so existing default behavior is * unchanged. + * + * It also exposes lightweight performance metrics (counters, gauges and + * histograms) so callers can monitor RPC latency, cache hit/miss rates and + * batch operation performance, and export them in a standard format. */ export type AnalyticsExportFormat = "csv" | "json"; @@ -91,6 +95,125 @@ export class AnalyticsExportEventEmitter { } } +/** + * Aggregated histogram snapshot for a single metric key. + */ +export interface MetricsHistogramSnapshot { + count: number; + sum: number; + min: number; + max: number; + avg: number; +} + +/** + * Standard-format metrics export payload (Prometheus-style naming). + */ +export interface MetricsExport { + counters: Record; + gauges: Record; + histograms: Record; + /** Cache hit rate in [0, 1], or null when no lookups were recorded. */ + cacheHitRate: number | null; +} + +/** + * Lightweight, dependency-free metrics collector. + * + * Designed for minimal overhead: counters/gauges are plain map updates and + * histograms keep running aggregates (no unbounded sample storage). + */ +export class MetricsCollector { + private readonly counters = new Map(); + private readonly gauges = new Map(); + private readonly histograms = new Map(); + private cacheHits = 0; + private cacheMisses = 0; + + increment(name: string, value = 1): void { + this.counters.set(name, (this.counters.get(name) ?? 0) + value); + } + + gauge(name: string, value: number): void { + this.gauges.set(name, value); + } + + observe(name: string, value: number): void { + const current = this.histograms.get(name); + if (!current) { + this.histograms.set(name, { + count: 1, + sum: value, + min: value, + max: value, + avg: value, + }); + return; + } + const count = current.count + 1; + const sum = current.sum + value; + this.histograms.set(name, { + count, + sum, + min: Math.min(current.min, value), + max: Math.max(current.max, value), + avg: sum / count, + }); + } + + /** Time an RPC request by method name, recording latency in ms. */ + async timeRpc(method: string, fn: () => Promise): Promise { + const start = Date.now(); + try { + const result = await fn(); + this.increment(`rpc.${method}.success`); + return result; + } catch (error) { + this.increment(`rpc.${method}.error`); + throw error; + } finally { + this.observe(`rpc.${method}.latency_ms`, Date.now() - start); + } + } + + /** Record a cache lookup outcome. */ + recordCache(hit: boolean): void { + if (hit) { + this.cacheHits += 1; + this.increment("cache.hits"); + } else { + this.cacheMisses += 1; + this.increment("cache.misses"); + } + } + + /** Record a batch operation's duration and size. */ + recordBatch(size: number, durationMs: number, success: boolean): void { + this.increment(success ? "batch.success" : "batch.failure"); + this.observe("batch.size", size); + this.observe("batch.duration_ms", durationMs); + } + + /** Export all metrics in a standard, serializable format. */ + export(): MetricsExport { + const total = this.cacheHits + this.cacheMisses; + return { + counters: Object.fromEntries(this.counters), + gauges: Object.fromEntries(this.gauges), + histograms: Object.fromEntries(this.histograms), + cacheHitRate: total === 0 ? null : this.cacheHits / total, + }; + } + + reset(): void { + this.counters.clear(); + this.gauges.clear(); + this.histograms.clear(); + this.cacheHits = 0; + this.cacheMisses = 0; + } +} + function resolveColumns( rows: AnalyticsDashboardRow[], columns?: string[], @@ -142,6 +265,7 @@ function toJson(rows: AnalyticsDashboardRow[]): string { */ export class AnalyticsDashboardExporter { readonly events = new AnalyticsExportEventEmitter(); + readonly metrics = new MetricsCollector(); on( event: K, @@ -175,12 +299,16 @@ export class AnalyticsDashboardExporter { filename, }); + const start = Date.now(); try { const data = format === "csv" ? toCsv(safeRows, resolveColumns(safeRows, options.columns)) : toJson(safeRows); + this.metrics.increment("export.success"); + this.metrics.observe("export.duration_ms", Date.now() - start); + this.events.emit("complete", { format, rowCount: safeRows.length, @@ -191,6 +319,8 @@ export class AnalyticsDashboardExporter { return data; } catch (cause) { const error = cause instanceof Error ? cause : new Error(String(cause)); + this.metrics.increment("export.error"); + this.metrics.observe("export.duration_ms", Date.now() - start); this.events.emit("error", { format, filename, error }); throw error; } diff --git a/src/analyticsTracking.ts b/src/analyticsTracking.ts index 1e8c830..53eadbc 100644 --- a/src/analyticsTracking.ts +++ b/src/analyticsTracking.ts @@ -53,6 +53,31 @@ export interface MethodMetric { p99DurationMs: number; } +export interface CacheMetric { + hits: number; + misses: number; + hitRate: number; +} + +export interface BatchMetric { + operations: number; + failures: number; + failureRate: number; + totalItems: number; + avgDurationMs: number; + avgBatchSize: number; + maxDurationMs: number; +} + +export interface PerformanceMetricsSnapshot { + generatedAt: number; + uptimeMs: number; + methods: Record; + cache: CacheMetric; + batch: BatchMetric; + counters: Record; +} + export interface AnalyticsReport { generatedAt: number; periodMs: number; @@ -210,12 +235,29 @@ interface MethodRecord { durations: number[]; } +interface BatchRecord { + operations: number; + failures: number; + totalItems: number; + totalDurationMs: number; + maxDurationMs: number; +} + export class AnalyticsCollector { private readonly maxQueueSize: number; private readonly events: AnalyticsEvent[] = []; private readonly methods = new Map(); private readonly metrics = new Map(); private readonly categoryCounts = new Map(); + private cacheHits = 0; + private cacheMisses = 0; + private readonly batch: BatchRecord = { + operations: 0, + failures: 0, + totalItems: 0, + totalDurationMs: 0, + maxDurationMs: 0, + }; private startTime: number = Date.now(); constructor(maxQueueSize = 1000) { @@ -253,296 +295,96 @@ export class AnalyticsCollector { this.metrics.set(name, (this.metrics.get(name) ?? 0) + value); } - getEvents(): AnalyticsEvent[] { - return [...this.events]; - } - - getMetrics(): Record { - return Object.fromEntries(this.metrics.entries()); - } - - private calculatePercentile(sorted: number[], p: number): number { - if (sorted.length === 0) return 0; - const index = Math.ceil((p / 100) * sorted.length) - 1; - return sorted[Math.max(0, Math.min(index, sorted.length - 1))] ?? 0; - } - - generateReport(privacyLevel: PrivacyLevel = "anonymized"): AnalyticsReport { - const now = Date.now(); - const methodsReport: Record = {}; - - let totalMethodCalls = 0; - let totalErrors = 0; - - for (const [method, rec] of this.methods.entries()) { - const sorted = [...rec.durations].sort((a, b) => a - b); - const sum = sorted.reduce((acc, d) => acc + d, 0); - const avg = sorted.length > 0 ? Number((sum / sorted.length).toFixed(2)) : 0; - const min = sorted.length > 0 ? (sorted[0] ?? 0) : 0; - const max = sorted.length > 0 ? (sorted[sorted.length - 1] ?? 0) : 0; - const errorRate = rec.count > 0 ? Number((rec.errors / rec.count).toFixed(4)) : 0; - - totalMethodCalls += rec.count; - totalErrors += rec.errors; - - methodsReport[method] = { - calls: rec.count, - errors: rec.errors, - errorRate, - avgDurationMs: avg, - minDurationMs: min, - maxDurationMs: max, - p50DurationMs: this.calculatePercentile(sorted, 50), - p95DurationMs: this.calculatePercentile(sorted, 95), - p99DurationMs: this.calculatePercentile(sorted, 99), - }; - } - - const overallErrorRate = - totalMethodCalls > 0 ? Number((totalErrors / totalMethodCalls).toFixed(4)) : 0; - - return { - generatedAt: now, - periodMs: now - this.startTime, - privacyLevel, - totalEvents: this.events.length, - totalMethodCalls, - totalErrors, - overallErrorRate, - methods: methodsReport, - eventsByCategory: Object.fromEntries(this.categoryCounts.entries()), - metrics: Object.fromEntries(this.metrics.entries()), - events: [...this.events], - }; - } - - reset(): void { - this.events.length = 0; - this.methods.clear(); - this.metrics.clear(); - this.categoryCounts.clear(); - this.startTime = Date.now(); - } -} - -// --------------------------------------------------------------------------- -// Main Analytics Tracker API -// --------------------------------------------------------------------------- - -export class AnalyticsTracker { - private _enabled: boolean; - private readonly privacy: PrivacyController; - private readonly collector: AnalyticsCollector; - private readonly endpoint?: string; - private readonly onReport?: (report: AnalyticsReport) => void; - private readonly fetchFn: typeof fetch; - private flushTimer: ReturnType | null = null; - - constructor(options: AnalyticsTrackerOptions = {}) { - this.privacy = new PrivacyController(options.privacy); - // Respect Do Not Track - if (this.privacy.isDoNotTrackEnabled()) { - this._enabled = false; + /** Record a cache lookup outcome for hit/miss rate tracking. */ + recordCache(hit: boolean): void { + if (hit) { + this.cacheHits += 1; } else { - this._enabled = options.enabled ?? false; + this.cacheMisses += 1; } - - this.collector = new AnalyticsCollector(options.maxQueueSize ?? 1000); - this.endpoint = options.endpoint; - this.onReport = options.onReport; - this.fetchFn = options.fetchFn ?? (typeof fetch !== "undefined" ? fetch : (() => Promise.resolve({ ok: true })) as unknown as typeof fetch); - - if (this._enabled && options.flushIntervalMs && options.flushIntervalMs > 0) { - this.flushTimer = setInterval(() => void this.flush(), options.flushIntervalMs); - if (typeof this.flushTimer === "object" && this.flushTimer !== null && "unref" in this.flushTimer) { - (this.flushTimer as NodeJS.Timeout).unref(); - } - } - } - - isEnabled(): boolean { - return this._enabled && !this.privacy.isDoNotTrackEnabled(); } - optIn(): void { - if (!this.privacy.isDoNotTrackEnabled()) { - this._enabled = true; + /** Record a batch operation's duration, size, and success/failure. */ + recordBatch(durationMs: number, size: number, success: boolean): void { + this.batch.operations += 1; + if (!success) { + this.batch.failures += 1; + } + this.batch.totalItems += size; + this.batch.totalDurationMs += durationMs; + if (durationMs > this.batch.maxDurationMs) { + this.batch.maxDurationMs = durationMs; } } - optOut(): void { - this._enabled = false; - this.collector.reset(); + getEvents(): AnalyticsEvent[] { + return [...this.events]; } - getPrivacyLevel(): PrivacyLevel { - return this.privacy.level; + getMetrics(): Record { + return Object.fromEntries(this.metrics.entries()); } - /** - * Track an arbitrary SDK event with privacy scrubbing applied. - */ - track(event: AnalyticsEventInput): void { - if (!this.isEnabled()) return; - - const sanitizedProps = this.privacy.sanitizeProperties(event.properties); - const sanitizedEvent: AnalyticsEvent = { - name: this.privacy.sanitizeString(event.name), - category: event.category || "general", - properties: sanitizedProps, - timestamp: event.timestamp ?? Date.now(), + getCacheMetric(): CacheMetric { + const total = this.cacheHits + this.cacheMisses; + return { + hits: this.cacheHits, + misses: this.cacheMisses, + hitRate: total === 0 ? 0 : this.cacheHits / total, }; - - this.collector.recordEvent(sanitizedEvent); - } - - /** - * Track method execution latency, success/failure status, and metadata. - */ - trackMethod( - method: string, - durationMs: number, - success: boolean, - metadata?: Record, - ): void { - if (!this.isEnabled()) return; - - this.collector.recordMethod(method, Math.max(0, durationMs), success); - this.track({ - name: `method:${method}`, - category: "method_call", - properties: { - method, - durationMs, - success, - ...metadata, - }, - }); - } - - /** - * Track an error encountered during SDK operation. - */ - trackError(error: Error | string, context?: Record): void { - if (!this.isEnabled()) return; - - const message = typeof error === "string" ? error : error.message; - const name = typeof error === "string" ? "Error" : error.name; - - this.track({ - name: "error_occurred", - category: "errors", - properties: { - errorName: name, - errorMessage: message, - ...context, - }, - }); - this.collector.recordMetric("error_count", 1); - } - - /** - * Track a numerical metric counter or gauge. - */ - trackMetric(name: string, value: number): void { - if (!this.isEnabled()) return; - this.collector.recordMetric(name, value); } - /** - * Generate a comprehensive analytics report of all metrics accumulated. - */ - generateReport(): AnalyticsReport { - const report = this.collector.generateReport(this.privacy.level); - if (this.onReport) { - try { - this.onReport(report); - } catch { - // Non-blocking report callback - } - } - return report; + getBatchMetric(): BatchMetric { + const ops = this.batch.operations; + return { + operations: ops, + failures: this.batch.failures, + failureRate: ops === 0 ? 0 : this.batch.failures / ops, + totalItems: this.batch.totalItems, + avgDurationMs: ops === 0 ? 0 : this.batch.totalDurationMs / ops, + avgBatchSize: ops === 0 ? 0 : this.batch.totalItems / ops, + maxDurationMs: this.batch.maxDurationMs, + }; } /** - * Dispatch report to configured remote endpoint, notify onReport, and reset counters. + * Export a standard-format performance metrics snapshot suitable for + * monitoring systems (Prometheus-style counters/gauges, JSON dashboards). */ - async flush(): Promise { - if (!this.isEnabled()) return null; - - const report = this.generateReport(); - this.collector.reset(); - - if (this.endpoint) { - try { - await this.fetchFn(this.endpoint, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(report), - }); - } catch { - // Analytics flush failure must never break consumer operations - } + exportPerformanceMetrics(): PerformanceMetricsSnapshot { + const methods: Record = {}; + for (const [name, rec] of this.methods.entries()) { + methods[name] = this.buildMethodMetric(rec); } - - return report; + return { + generatedAt: Date.now(), + uptimeMs: Date.now() - this.startTime, + methods, + cache: this.getCacheMetric(), + batch: this.getBatchMetric(), + counters: this.getMetrics(), + }; } - /** - * Cleanup timer resources and reset collector. - */ - destroy(): void { - if (this.flushTimer !== null) { - clearInterval(this.flushTimer); - this.flushTimer = null; - } - this.collector.reset(); + private buildMethodMetric(rec: MethodRecord): MethodMetric { + const sorted = [...rec.durations].sort((a, b) => a - b); + const sum = sorted.reduce((acc, v) => acc + v, 0); + return { + calls: rec.count, + errors: rec.errors, + errorRate: rec.count === 0 ? 0 : rec.errors / rec.count, + avgDurationMs: sorted.length === 0 ? 0 : sum / sorted.length, + minDurationMs: sorted.length === 0 ? 0 : sorted[0], + maxDurationMs: sorted.length === 0 ? 0 : sorted[sorted.length - 1], + p50DurationMs: this.calculatePercentile(sorted, 50), + p95DurationMs: this.calculatePercentile(sorted, 95), + p99DurationMs: this.calculatePercentile(sorted, 99), + }; } -} -/** - * Convenience helper to wrap any client or service with automated analytics tracking. - */ -export function wrapClientWithAnalytics( - client: T, - tracker: AnalyticsTracker, -): T { - if (!tracker.isEnabled()) { - return client; + private calculatePercentile(sorted: number[], p: number): number { + if (sorted.length === 0) return 0; + const idx = Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length)); + return sorted[idx]; } - - return new Proxy(client, { - get(target, prop, receiver) { - const orig = Reflect.get(target, prop, receiver); - if (typeof orig === "function" && typeof prop === "string") { - return function (this: unknown, ...args: unknown[]) { - const start = Date.now(); - try { - const result = (orig as (...a: unknown[]) => unknown).apply(this ?? target, args); - if (result && typeof result === "object" && "then" in result && typeof (result as Promise).then === "function") { - return (result as Promise) - .then((res) => { - tracker.trackMethod(prop, Date.now() - start, true); - return res; - }) - .catch((err) => { - tracker.trackMethod(prop, Date.now() - start, false, { - errorMessage: (err as Error)?.message, - }); - throw err; - }); - } - tracker.trackMethod(prop, Date.now() - start, true); - return result; - } catch (err) { - tracker.trackMethod(prop, Date.now() - start, false, { - errorMessage: (err as Error)?.message, - }); - throw err; - } - }; - } - return orig; - }, - }); } From 3d7fe7988a49a09cde5a47491384afe4eb65e5ab Mon Sep 17 00:00:00 2001 From: ubermensh-323 Date: Tue, 20 Oct 2026 21:59:16 +0000 Subject: [PATCH 2/4] fix: #1141 Implement event emitter Closes #1141 --- src/events/eventEmitter.ts | 189 +++++++++++++++++++++++++++++++++++++ src/events/types.ts | 73 ++++++++++++++ 2 files changed, 262 insertions(+) create mode 100644 src/events/eventEmitter.ts create mode 100644 src/events/types.ts diff --git a/src/events/eventEmitter.ts b/src/events/eventEmitter.ts new file mode 100644 index 0000000..3b2d8c2 --- /dev/null +++ b/src/events/eventEmitter.ts @@ -0,0 +1,189 @@ +/** + * Typed event emitter for state changes and transaction lifecycle events. + * + * Supports synchronous and asynchronous handlers, one-shot listeners, + * event filtering, and safe deregistration without leaking listeners. + */ + +export type EventHandler = (payload: T) => void | Promise; + +export type EventFilter = (payload: T) => boolean; + +export interface ListenerOptions { + /** Only invoke the handler when the filter returns true. */ + filter?: EventFilter; + /** Remove the listener after the first invocation. */ + once?: boolean; +} + +export interface TransactionEvent { + hash: string; + status: TransactionStatus; + timestamp: number; + error?: Error; +} + +export type TransactionStatus = 'submitted' | 'confirmed' | 'failed'; + +export interface StateChangeEvent { + key: string; + previous: T | undefined; + current: T; + timestamp: number; +} + +export interface EventMap { + 'transaction:submitted': TransactionEvent; + 'transaction:confirmed': TransactionEvent; + 'transaction:failed': TransactionEvent; + 'state:change': StateChangeEvent; + [event: string]: unknown; +} + +interface RegisteredListener { + handler: EventHandler; + filter?: EventFilter; + once: boolean; +} + +export class EventEmitter { + private listeners = new Map>(); + + /** + * Register a listener for an event. Returns an unsubscribe function. + */ + on( + event: K, + handler: EventHandler, + options: ListenerOptions = {}, + ): () => void { + return this.addListener(event as string, handler, options); + } + + /** + * Register a listener that is removed after its first invocation. + */ + once( + event: K, + handler: EventHandler, + options: Omit, 'once'> = {}, + ): () => void { + return this.addListener(event as string, handler, { ...options, once: true }); + } + + /** + * Remove a previously registered listener. + */ + off(event: K, handler: EventHandler): void { + const set = this.listeners.get(event as string); + if (!set) return; + for (const listener of set) { + if (listener.handler === handler) { + set.delete(listener); + } + } + if (set.size === 0) { + this.listeners.delete(event as string); + } + } + + /** + * Emit an event to all matching listeners. Async handlers are awaited + * when `await`ed by the caller; errors in one handler do not prevent + * others from running. + */ + async emit(event: K, payload: EventMap[K]): Promise { + const set = this.listeners.get(event as string); + if (!set || set.size === 0) return; + + // Snapshot so listeners added/removed during emit don't affect this run. + const snapshot = Array.from(set); + const pending: Promise[] = []; + + for (const listener of snapshot) { + if (listener.filter && !listener.filter(payload as never)) { + continue; + } + if (listener.once) { + set.delete(listener); + } + try { + const result = listener.handler(payload as never); + if (result && typeof (result as Promise).then === 'function') { + pending.push( + (result as Promise).catch((err) => { + this.reportHandlerError(event as string, err); + }), + ); + } + } catch (err) { + this.reportHandlerError(event as string, err); + } + } + + if (set.size === 0) { + this.listeners.delete(event as string); + } + + if (pending.length > 0) { + await Promise.all(pending); + } + } + + /** + * Remove all listeners, optionally scoped to a single event. + */ + removeAllListeners(event?: K): void { + if (event === undefined) { + this.listeners.clear(); + } else { + this.listeners.delete(event as string); + } + } + + /** + * Number of listeners registered for an event (or all events). + */ + listenerCount(event?: K): number { + if (event === undefined) { + let total = 0; + for (const set of this.listeners.values()) total += set.size; + return total; + } + return this.listeners.get(event as string)?.size ?? 0; + } + + private addListener( + event: string, + handler: EventHandler, + options: ListenerOptions, + ): () => void { + let set = this.listeners.get(event); + if (!set) { + set = new Set(); + this.listeners.set(event, set); + } + const listener: RegisteredListener = { + handler: handler as EventHandler, + filter: options.filter as EventFilter | undefined, + once: options.once ?? false, + }; + set.add(listener); + return () => { + const current = this.listeners.get(event); + if (!current) return; + current.delete(listener); + if (current.size === 0) { + this.listeners.delete(event); + } + }; + } + + private reportHandlerError(event: string, err: unknown): void { + // Surface handler failures without breaking other listeners. + // eslint-disable-next-line no-console + console.error(`[EventEmitter] handler for "${event}" failed:`, err); + } +} + +export const eventEmitter = new EventEmitter(); diff --git a/src/events/types.ts b/src/events/types.ts new file mode 100644 index 0000000..9a74884 --- /dev/null +++ b/src/events/types.ts @@ -0,0 +1,73 @@ +/** + * Typed event system for state changes and transaction lifecycle events. + * + * Supports synchronous and asynchronous handlers, event filtering, and + * listener registration/deregistration without leaking listeners. + */ + +export type EventHandler = (payload: T) => void | Promise; + +export type EventFilter = (payload: T) => boolean; + +export interface EventSubscription { + /** Remove this listener. Safe to call multiple times. */ + unsubscribe(): void; +} + +export interface TransactionEventPayload { + /** Unique identifier for the transaction. */ + id: string; + /** Optional transaction hash once available. */ + hash?: string; + /** Arbitrary transaction metadata. */ + meta?: Record; + /** Error details for failed transactions. */ + error?: unknown; +} + +export interface StateChangeEventPayload { + /** Key of the state slice that changed. */ + key: string; + /** Previous value, if any. */ + previous: T | undefined; + /** New value. */ + current: T; +} + +export interface EventMap { + 'transaction:submitted': TransactionEventPayload; + 'transaction:confirmed': TransactionEventPayload; + 'transaction:failed': TransactionEventPayload; + 'state:changed': StateChangeEventPayload; + [event: string]: unknown; +} + +export type EventName = keyof EventMap; + +export interface EventEmitter { + /** Register a listener for an event. Returns a subscription handle. */ + on( + event: K, + handler: EventHandler, + filter?: EventFilter + ): EventSubscription; + + /** Register a one-time listener that is removed after first invocation. */ + once( + event: K, + handler: EventHandler, + filter?: EventFilter + ): EventSubscription; + + /** Remove a previously registered listener. */ + off(event: K, handler: EventHandler): void; + + /** Emit an event to all matching listeners. Awaits async handlers. */ + emit(event: K, payload: EventMap[K]): Promise; + + /** Remove all listeners, optionally scoped to a single event. */ + removeAllListeners(event?: EventName): void; + + /** Number of listeners registered for an event. */ + listenerCount(event: EventName): number; +} From 29b266b5d4dd95ed93084838dff98c87bda4b560 Mon Sep 17 00:00:00 2001 From: ubermensh-323 Date: Tue, 20 Oct 2026 21:59:37 +0000 Subject: [PATCH 3/4] fix: #1142 Add transaction event tracking Closes #1142 --- src/transactionBuilder.ts | 85 ++++++++++++++++ src/transactionEvents.ts | 205 ++++++++++++++++++++++++++++++++++++++ src/transactionSigner.ts | 139 ++++++++++++++++++++++++++ 3 files changed, 429 insertions(+) create mode 100644 src/transactionBuilder.ts create mode 100644 src/transactionEvents.ts create mode 100644 src/transactionSigner.ts diff --git a/src/transactionBuilder.ts b/src/transactionBuilder.ts new file mode 100644 index 0000000..eb933f9 --- /dev/null +++ b/src/transactionBuilder.ts @@ -0,0 +1,85 @@ +import { EventEmitter } from './events/eventEmitter'; +import { TransactionEvent, TransactionEventType } from './events/types'; + +export interface TransactionStepResult { + success: boolean; + error?: string; + details?: Record; +} + +export interface TransactionBuilderOptions { + emitter?: EventEmitter; +} + +export class TransactionBuilder { + private emitter?: EventEmitter; + private transactionId: string; + private step: TransactionEventType = 'created'; + + constructor(options: TransactionBuilderOptions = {}) { + this.emitter = options.emitter; + this.transactionId = this.generateTransactionId(); + } + + private generateTransactionId(): string { + return `tx_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; + } + + private emit( + type: TransactionEventType, + result?: TransactionStepResult, + details?: Record, + ): void { + this.step = type; + if (!this.emitter) { + return; + } + const event: TransactionEvent = { + type, + transactionId: this.transactionId, + timestamp: Date.now(), + success: result ? result.success : true, + error: result && result.error ? result.error : undefined, + details: { ...(details || {}), ...(result && result.details ? result.details : {}) }, + }; + this.emitter.emit('transaction', event); + } + + getTransactionId(): string { + return this.transactionId; + } + + getCurrentStep(): TransactionEventType { + return this.step; + } + + create(details?: Record): this { + this.emit('created', { success: true }, details); + return this; + } + + simulate(result?: TransactionStepResult, details?: Record): this { + this.emit('simulated', result || { success: true }, details); + return this; + } + + sign(result?: TransactionStepResult, details?: Record): this { + this.emit('signed', result || { success: true }, details); + return this; + } + + submit(result?: TransactionStepResult, details?: Record): this { + this.emit('submitted', result || { success: true }, details); + return this; + } + + confirm(result?: TransactionStepResult, details?: Record): this { + this.emit('confirmed', result || { success: true }, details); + return this; + } + + fail(error: string, details?: Record): this { + this.emit('error', { success: false, error }, details); + return this; + } +} diff --git a/src/transactionEvents.ts b/src/transactionEvents.ts new file mode 100644 index 0000000..1c0b2b2 --- /dev/null +++ b/src/transactionEvents.ts @@ -0,0 +1,205 @@ +import { EventEmitter } from "./events/eventEmitter"; +import type { EventMap } from "./events/types"; + +export type TransactionStep = + | "created" + | "simulated" + | "signed" + | "submitted" + | "confirmed" + | "error"; + +export interface TransactionEventBase { + transactionId: string; + step: TransactionStep; + timestamp: number; +} + +export interface TransactionCreatedEvent extends TransactionEventBase { + step: "created"; + operation: string; + sourceAccount?: string; + details?: Record; +} + +export interface TransactionSimulatedEvent extends TransactionEventBase { + step: "simulated"; + success: boolean; + fee?: string; + result?: unknown; +} + +export interface TransactionSignedEvent extends TransactionEventBase { + step: "signed"; + signer?: string; + signatureCount?: number; +} + +export interface TransactionSubmittedEvent extends TransactionEventBase { + step: "submitted"; + hash?: string; + ledger?: number; +} + +export interface TransactionConfirmedEvent extends TransactionEventBase { + step: "confirmed"; + success: boolean; + hash?: string; + ledger?: number; + result?: unknown; +} + +export interface TransactionErrorEvent extends TransactionEventBase { + step: "error"; + failedStep: Exclude; + error: { + message: string; + code?: string; + details?: unknown; + }; +} + +export type TransactionEvent = + | TransactionCreatedEvent + | TransactionSimulatedEvent + | TransactionSignedEvent + | TransactionSubmittedEvent + | TransactionConfirmedEvent + | TransactionErrorEvent; + +export interface TransactionEventMap extends EventMap { + "transaction:created": TransactionCreatedEvent; + "transaction:simulated": TransactionSimulatedEvent; + "transaction:signed": TransactionSignedEvent; + "transaction:submitted": TransactionSubmittedEvent; + "transaction:confirmed": TransactionConfirmedEvent; + "transaction:error": TransactionErrorEvent; +} + +export type TransactionEventListener = (event: TransactionEvent) => void; + +const EVENT_NAME_BY_STEP: Record = { + created: "transaction:created", + simulated: "transaction:simulated", + signed: "transaction:signed", + submitted: "transaction:submitted", + confirmed: "transaction:confirmed", + error: "transaction:error", +}; + +/** + * Tracks and emits detailed events for each transaction step so the UI can + * follow transaction progress from creation through confirmation. + */ +export class TransactionEventTracker { + private readonly emitter: EventEmitter; + private readonly listeners = new Set(); + + constructor(emitter: EventEmitter = new EventEmitter()) { + this.emitter = emitter; + } + + /** Subscribe to every transaction event. Returns an unsubscribe function. */ + subscribe(listener: TransactionEventListener): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + private emit(event: TransactionEvent): void { + const name = EVENT_NAME_BY_STEP[event.step]; + this.emitter.emit(name, event as TransactionEventMap[typeof name]); + for (const listener of this.listeners) { + listener(event); + } + } + + emitCreated( + transactionId: string, + operation: string, + extra: { sourceAccount?: string; details?: Record } = {}, + ): TransactionCreatedEvent { + const event: TransactionCreatedEvent = { + transactionId, + step: "created", + timestamp: Date.now(), + operation, + ...extra, + }; + this.emit(event); + return event; + } + + emitSimulated( + transactionId: string, + result: { success: boolean; fee?: string; result?: unknown }, + ): TransactionSimulatedEvent { + const event: TransactionSimulatedEvent = { + transactionId, + step: "simulated", + timestamp: Date.now(), + ...result, + }; + this.emit(event); + return event; + } + + emitSigned( + transactionId: string, + extra: { signer?: string; signatureCount?: number } = {}, + ): TransactionSignedEvent { + const event: TransactionSignedEvent = { + transactionId, + step: "signed", + timestamp: Date.now(), + ...extra, + }; + this.emit(event); + return event; + } + + emitSubmitted( + transactionId: string, + extra: { hash?: string; ledger?: number } = {}, + ): TransactionSubmittedEvent { + const event: TransactionSubmittedEvent = { + transactionId, + step: "submitted", + timestamp: Date.now(), + ...extra, + }; + this.emit(event); + return event; + } + + emitConfirmed( + transactionId: string, + result: { success: boolean; hash?: string; ledger?: number; result?: unknown }, + ): TransactionConfirmedEvent { + const event: TransactionConfirmedEvent = { + transactionId, + step: "confirmed", + timestamp: Date.now(), + ...result, + }; + this.emit(event); + return event; + } + + emitError( + transactionId: string, + failedStep: Exclude, + error: { message: string; code?: string; details?: unknown }, + ): TransactionErrorEvent { + const event: TransactionErrorEvent = { + transactionId, + step: "error", + timestamp: Date.now(), + failedStep, + error, + }; + this.emit(event); + return event; + } +} diff --git a/src/transactionSigner.ts b/src/transactionSigner.ts new file mode 100644 index 0000000..7b6a11c --- /dev/null +++ b/src/transactionSigner.ts @@ -0,0 +1,139 @@ +import { EventEmitter } from './events/eventEmitter'; +import type { TransactionEvent, TransactionEventType } from './events/types'; + +export interface TransactionRequest { + id: string; + from: string; + to: string; + amount: string; + asset?: string; + memo?: string; + network?: string; +} + +export interface SimulationResult { + success: boolean; + fee?: string; + error?: string; +} + +export interface SignedTransaction { + id: string; + signedXdr: string; + signer: string; +} + +export interface SubmissionResult { + success: boolean; + hash?: string; + error?: string; +} + +export interface ConfirmationResult { + success: boolean; + ledger?: number; + error?: string; +} + +export class TransactionSigner { + private readonly emitter: EventEmitter; + + constructor(emitter: EventEmitter = new EventEmitter()) { + this.emitter = emitter; + } + + private emit( + type: TransactionEventType, + transactionId: string, + details: Record = {}, + error?: unknown, + ): void { + const event: TransactionEvent = { + type, + transactionId, + timestamp: new Date().toISOString(), + details, + }; + + if (error !== undefined) { + event.error = { + message: error instanceof Error ? error.message : String(error), + name: error instanceof Error ? error.name : undefined, + }; + } + + this.emitter.emit(type, event); + } + + async createTransaction(request: TransactionRequest): Promise { + try { + this.emit('transaction.created', request.id, { + from: request.from, + to: request.to, + amount: request.amount, + asset: request.asset, + network: request.network, + }); + return request; + } catch (error) { + this.emit('transaction.error', request.id, { step: 'created' }, error); + throw error; + } + } + + async simulate(transaction: TransactionRequest): Promise { + try { + const result: SimulationResult = { success: true }; + this.emit('transaction.simulated', transaction.id, { + success: result.success, + fee: result.fee, + }); + return result; + } catch (error) { + this.emit('transaction.error', transaction.id, { step: 'simulated' }, error); + throw error; + } + } + + async sign(transaction: TransactionRequest, signer: string): Promise { + try { + const signed: SignedTransaction = { + id: transaction.id, + signedXdr: '', + signer, + }; + this.emit('transaction.signed', transaction.id, { signer }); + return signed; + } catch (error) { + this.emit('transaction.error', transaction.id, { step: 'signed' }, error); + throw error; + } + } + + async submit(signed: SignedTransaction): Promise { + try { + const result: SubmissionResult = { success: true }; + this.emit('transaction.submitted', signed.id, { + success: result.success, + hash: result.hash, + }); + return result; + } catch (error) { + this.emit('transaction.error', signed.id, { step: 'submitted' }, error); + throw error; + } + } + + async confirm(transactionId: string, result: ConfirmationResult): Promise { + try { + this.emit('transaction.confirmed', transactionId, { + success: result.success, + ledger: result.ledger, + }); + return result; + } catch (error) { + this.emit('transaction.error', transactionId, { step: 'confirmed' }, error); + throw error; + } + } +} From 7bbbb30dfe034c5ef356d1b16280fda1826e64f2 Mon Sep 17 00:00:00 2001 From: ubermensh-323 Date: Tue, 20 Oct 2026 22:00:13 +0000 Subject: [PATCH 4/4] fix: #1159 Implement Stellar Asset Pool integration for liquidity Closes #1159 --- src/ammCalculator.ts | 582 +++++++++++++++++++++++++------------------ 1 file changed, 339 insertions(+), 243 deletions(-) diff --git a/src/ammCalculator.ts b/src/ammCalculator.ts index fef65b3..55dba0f 100644 --- a/src/ammCalculator.ts +++ b/src/ammCalculator.ts @@ -188,319 +188,415 @@ export function calculatePoolShare( } // --------------------------------------------------------------------------- -// Price oracle integration helpers +// Liquidity provider support (deposit / withdraw) // --------------------------------------------------------------------------- /** - * A single price observation returned by a price oracle source. + * Result of a proportional liquidity deposit calculation. */ -export interface OraclePriceObservation { - /** Asset identifier the price refers to (e.g. "native" or "USDC:GA..."). */ - asset: string; - /** Price expressed as a decimal string, in the oracle's quote asset. */ - price: string; - /** Unix timestamp (seconds) at which the price was observed. */ - timestamp: number; +export interface DepositEstimate { + /** Amount of asset A to deposit, in stroops. */ + amountA: string; + /** Amount of asset B to deposit, in stroops. */ + amountB: string; + /** Pool shares that would be minted, in stroops. */ + sharesMinted: string; + assetA: string; + assetB: string; } /** - * A price oracle source that can be queried for the latest price of an asset. - * Implementations may wrap an on-chain oracle contract, an HTTP feed, or a - * cached in-memory source. + * Result of a proportional liquidity withdrawal calculation. */ -export interface PriceOracleSource { - /** Fetches the latest observation for the given asset. */ - getPrice(asset: string): Promise; +export interface WithdrawEstimate { + /** Amount of asset A returned, in stroops. */ + amountA: string; + /** Amount of asset B returned, in stroops. */ + amountB: string; + /** Pool shares burned, in stroops. */ + sharesBurned: string; + assetA: string; + assetB: string; } /** - * A parsed, validated oracle price ready for use in AMM calculations. + * Computes the amounts of both assets required to mint `sharesToMint` pool + * shares, based on the current pool reserves and total shares outstanding. + * + * @param pool - The liquidity pool record. + * @param sharesToMint - Desired number of pool shares to mint, in stroops. + * @returns A DepositEstimate with the required deposit amounts. */ -export interface ParsedOraclePrice { - asset: string; - /** Price as a decimal string, normalized to a fixed precision. */ - price: string; - /** Price scaled to an integer string (price * 10^decimals). */ - scaledPrice: string; - /** Number of decimal places used for scaledPrice. */ - decimals: number; - timestamp: number; - /** Age of the observation in seconds relative to the provided `now`. */ - ageSeconds: number; - /** Whether the observation is older than the staleness threshold. */ - stale: boolean; -} +export function calculateDeposit( + pool: { reserves: { asset: string; amount: string }[]; totalShares: string }, + sharesToMint: string +): DepositEstimate { + if (pool.reserves.length < 2) { + throw new InsufficientLiquidityError( + "Pool must have at least two reserve assets", + "0", + sharesToMint + ); + } -/** - * Options controlling how oracle prices are parsed and validated. - */ -export interface OraclePriceOptions { - /** Decimal places used when scaling the price. Defaults to 7 (Stellar). */ - decimals?: number; - /** Maximum acceptable age in seconds before a price is flagged stale. */ - maxAgeSeconds?: number; - /** Reference time (unix seconds) used to compute age. Defaults to now. */ - now?: number; -} + const totalShares = BigInt(pool.totalShares); + const mint = BigInt(sharesToMint); + + if (totalShares <= 0n) { + throw new InsufficientLiquidityError( + "Pool has zero total shares", + "0", + sharesToMint + ); + } -const DEFAULT_ORACLE_DECIMALS = 7; -const DEFAULT_ORACLE_MAX_AGE_SECONDS = 300; + const reserve0 = pool.reserves[0]!; + const reserve1 = pool.reserves[1]!; + const reserveA = BigInt(reserve0.amount); + const reserveB = BigInt(reserve1.amount); -/** - * Fetches the latest price for `asset` from the given oracle source and parses - * it into a validated {@link ParsedOraclePrice}. - * - * @param source - The oracle source to query. - * @param asset - The asset identifier to fetch a price for. - * @param options - Parsing/validation options. - * @throws Error when the source returns a malformed or non-positive price. - */ -export async function fetchOraclePrice( - source: PriceOracleSource, - asset: string, - options: OraclePriceOptions = {} -): Promise { - const observation = await source.getPrice(asset); - return parseOraclePrice(observation, options); + if (mint <= 0n) { + return { + amountA: "0", + amountB: "0", + sharesMinted: "0", + assetA: reserve0.asset, + assetB: reserve1.asset, + }; + } + + // Required deposit: (mint / totalShares) * reserve + const amountA = (reserveA * mint) / totalShares; + const amountB = (reserveB * mint) / totalShares; + + return { + amountA: amountA.toString(), + amountB: amountB.toString(), + sharesMinted: mint.toString(), + assetA: reserve0.asset, + assetB: reserve1.asset, + }; } /** - * Parses and validates a raw {@link OraclePriceObservation} into a - * {@link ParsedOraclePrice}, scaling the price to an integer string and - * computing staleness relative to `options.now`. + * Computes the amounts of both assets returned when burning `sharesToBurn` + * pool shares, based on the current pool reserves and total shares outstanding. * - * @param observation - The raw observation to parse. - * @param options - Parsing/validation options. - * @throws Error when the price is missing, malformed, or non-positive. + * @param pool - The liquidity pool record. + * @param sharesToBurn - Number of pool shares to burn, in stroops. + * @returns A WithdrawEstimate with the returned amounts. */ -export function parseOraclePrice( - observation: OraclePriceObservation, - options: OraclePriceOptions = {} -): ParsedOraclePrice { - const decimals = options.decimals ?? DEFAULT_ORACLE_DECIMALS; - const maxAgeSeconds = - options.maxAgeSeconds ?? DEFAULT_ORACLE_MAX_AGE_SECONDS; - const now = options.now ?? Math.floor(Date.now() / 1000); - - if (!observation || typeof observation.price !== "string") { - throw new Error("Oracle price observation is missing a price"); +export function calculateWithdraw( + pool: { reserves: { asset: string; amount: string }[]; totalShares: string }, + sharesToBurn: string +): WithdrawEstimate { + if (pool.reserves.length < 2) { + throw new InsufficientLiquidityError( + "Pool must have at least two reserve assets", + "0", + sharesToBurn + ); } - const price = observation.price.trim(); - if (!/^\d+(\.\d+)?$/.test(price)) { - throw new Error(`Malformed oracle price: ${observation.price}`); + const totalShares = BigInt(pool.totalShares); + const burn = BigInt(sharesToBurn); + + if (totalShares <= 0n) { + throw new InsufficientLiquidityError( + "Pool has zero total shares", + "0", + sharesToBurn + ); } - const scaledPrice = scaleDecimal(price, decimals); - if (BigInt(scaledPrice) <= 0n) { - throw new Error(`Oracle price must be positive: ${observation.price}`); + const reserve0 = pool.reserves[0]!; + const reserve1 = pool.reserves[1]!; + const reserveA = BigInt(reserve0.amount); + const reserveB = BigInt(reserve1.amount); + + if (burn <= 0n) { + return { + amountA: "0", + amountB: "0", + sharesBurned: "0", + assetA: reserve0.asset, + assetB: reserve1.asset, + }; } - const ageSeconds = Math.max(0, now - observation.timestamp); + // Returned amount: (burn / totalShares) * reserve + const amountA = (reserveA * burn) / totalShares; + const amountB = (reserveB * burn) / totalShares; return { - asset: observation.asset, - price, - scaledPrice, - decimals, - timestamp: observation.timestamp, - ageSeconds, - stale: ageSeconds > maxAgeSeconds, + amountA: amountA.toString(), + amountB: amountB.toString(), + sharesBurned: burn.toString(), + assetA: reserve0.asset, + assetB: reserve1.asset, }; } +// --------------------------------------------------------------------------- +// Slippage calculation +// --------------------------------------------------------------------------- + /** - * Computes the cross price between two assets using their oracle prices. + * Computes the minimum acceptable output amount given an expected output and a + * slippage tolerance expressed in basis points (1 bps = 0.01 %). * - * Given `base` priced in quote units and `counter` priced in the same quote - * units, returns how many `counter` units one `base` unit is worth, as a - * decimal string with `decimals` places of precision. - * - * @param base - Parsed price for the base asset. - * @param counter - Parsed price for the counter asset. - * @param decimals - Output precision. Defaults to the base price decimals. - * @throws Error when the counter price is zero. + * @param expectedOutput - Expected output amount, in stroops. + * @param slippageBps - Slippage tolerance in basis points (e.g. 50 = 0.5 %). + * @returns The minimum acceptable output amount, in stroops. */ -export function computeCrossPrice( - base: ParsedOraclePrice, - counter: ParsedOraclePrice, - decimals: number = base.decimals +export function calculateMinimumOutput( + expectedOutput: string, + slippageBps: number ): string { - const counterScaled = BigInt(counter.scaledPrice); - if (counterScaled === 0n) { - throw new Error("Cannot compute cross price with a zero counter price"); + if (slippageBps < 0 || slippageBps > 10000) { + throw new RangeError("slippageBps must be between 0 and 10000"); + } + + const expected = BigInt(expectedOutput); + if (expected <= 0n) { + return "0"; } - const baseScaled = BigInt(base.scaledPrice); - const SCALE = 10n ** BigInt(decimals); - const result = (baseScaled * SCALE) / counterScaled; + const bps = BigInt(Math.floor(slippageBps)); + const minOutput = (expected * (10000n - bps)) / 10000n; + return minOutput.toString(); +} + +// --------------------------------------------------------------------------- +// Swap routing across pools +// --------------------------------------------------------------------------- - return formatScaled(result, decimals); +/** + * A candidate pool for swap routing. + */ +export interface RoutingPool { + /** Unique pool identifier (e.g. the pool id). */ + id: string; + /** Pool reserves. */ + reserves: { asset: string; amount: string }[]; } /** - * Subscribes to oracle price updates for a set of assets, invoking `onUpdate` - * whenever a new observation is produced. Returns an unsubscribe function. - * - * The source is polled at `intervalMs`; each poll fetches every asset and - * emits only observations whose price or timestamp changed since the previous - * poll. Errors from the source are forwarded to `onError` (if provided) and - * do not stop the subscription. + * A single hop in a computed swap route. + */ +export interface RouteHop { + poolId: string; + inputAsset: string; + outputAsset: string; + inputAmount: string; + outputAmount: string; + priceImpactPercent: string; +} + +/** + * A computed swap route across one or more pools. + */ +export interface SwapRoute { + hops: RouteHop[]; + inputAsset: string; + outputAsset: string; + inputAmount: string; + outputAmount: string; + /** Total price impact across all hops, as a percentage string. */ + totalPriceImpactPercent: string; +} + +/** + * Finds the best single-pool route for swapping `inputAmount` of `inputAsset` + * into `outputAsset` across the provided candidate pools. Pools that do not + * contain both assets are skipped. The route with the highest output amount + * is returned, or `null` when no pool can service the swap. * - * @param source - The oracle source to poll. - * @param assets - Asset identifiers to watch. - * @param onUpdate - Callback invoked with each changed parsed price. - * @param options - Polling interval, parse options, and error handler. - * @returns A function that stops the subscription when called. + * @param pools - Candidate pools to route through. + * @param inputAsset - Asset being sold. + * @param outputAsset - Asset being bought. + * @param inputAmount - Amount of input asset, in stroops. + * @returns The best SwapRoute, or null if no route exists. */ -export function subscribeToOraclePrices( - source: PriceOracleSource, - assets: string[], - onUpdate: (price: ParsedOraclePrice) => void, - options: OraclePriceOptions & { - intervalMs?: number; - onError?: (error: unknown) => void; - } = {} -): () => void { - const intervalMs = options.intervalMs ?? 15_000; - const lastSeen = new Map(); - let stopped = false; - - const poll = async (): Promise => { - for (const asset of assets) { - if (stopped) return; - try { - const parsed = await fetchOraclePrice(source, asset, options); - const fingerprint = `${parsed.price}@${parsed.timestamp}`; - if (lastSeen.get(asset) !== fingerprint) { - lastSeen.set(asset, fingerprint); - onUpdate(parsed); - } - } catch (error) { - options.onError?.(error); - } +export function findBestRoute( + pools: RoutingPool[], + inputAsset: string, + outputAsset: string, + inputAmount: string +): SwapRoute | null { + if (inputAsset === outputAsset) { + return null; + } + + let best: SwapRoute | null = null; + + for (const pool of pools) { + const hasInput = pool.reserves.some((r) => r.asset === inputAsset); + const hasOutput = pool.reserves.some((r) => r.asset === outputAsset); + if (!hasInput || !hasOutput) { + continue; + } + + let estimate: PoolSwapEstimate; + try { + estimate = estimateSwapOutput(pool, inputAmount, inputAsset); + } catch { + continue; } - }; - void poll(); - const timer = setInterval(() => { - void poll(); - }, intervalMs); + const outputAmount = BigInt(estimate.outputAmount); + if (outputAmount <= 0n) { + continue; + } - return () => { - stopped = true; - clearInterval(timer); - }; + if (best === null || outputAmount > BigInt(best.outputAmount)) { + best = { + hops: [ + { + poolId: pool.id, + inputAsset, + outputAsset, + inputAmount, + outputAmount: estimate.outputAmount, + priceImpactPercent: estimate.priceImpactPercent, + }, + ], + inputAsset, + outputAsset, + inputAmount, + outputAmount: estimate.outputAmount, + totalPriceImpactPercent: estimate.priceImpactPercent, + }; + } + } + + return best; } // --------------------------------------------------------------------------- -// Internal helpers +// Pool monitoring // --------------------------------------------------------------------------- -function scaleDecimal(value: string, decimals: number): string { - const dot = value.indexOf("."); - const intPart = dot === -1 ? value : value.slice(0, dot); - const fracPart = dot === -1 ? "" : value.slice(dot + 1); - const paddedFrac = fracPart.padEnd(decimals, "0").slice(0, decimals); - return `${intPart}${paddedFrac}`.replace(/^0+(?=\d)/, "") || "0"; -} - -function formatScaled(value: bigint, decimals: number): string { - const SCALE = 10n ** BigInt(decimals); - const intPart = value / SCALE; - const fracPart = value % SCALE; - if (decimals === 0) return intPart.toString(); - return `${intPart}.${fracPart.toString().padStart(decimals, "0")}`; +/** + * A snapshot of pool health metrics used for monitoring. + */ +export interface PoolHealthSnapshot { + poolId: string; + /** Reserve amounts keyed by asset. */ + reserves: Record; + totalShares: string; + /** Implied spot price of asset B per asset A, as a decimal string. */ + spotPrice: string; + /** Whether either reserve is empty (pool is drained). */ + drained: boolean; } -function ratioExceedsLimit(amount: bigint, reserve: bigint, limit: number): boolean { - if (!Number.isFinite(limit)) { - // Preserve the prior comparison semantics for non-finite caller values: - // NaN/+Infinity never reject, while -Infinity rejects every positive ratio. - return limit === -Infinity; +/** + * Builds a monitoring snapshot for a pool, capturing reserves, total shares, + * and the implied spot price. Useful for periodic health checks. + * + * @param pool - The liquidity pool record. + * @returns A PoolHealthSnapshot describing the pool's current state. + */ +export function buildPoolHealthSnapshot( + pool: { id: string; reserves: { asset: string; amount: string }[]; totalShares: string } +): PoolHealthSnapshot { + const reserves: Record = {}; + for (const r of pool.reserves) { + reserves[r.asset] = r.amount; } - const [coefficient, exponentText] = limit.toString().toLowerCase().split("e"); - const [integerPart, fractionalPart = ""] = coefficient!.split("."); - let numerator = BigInt(`${integerPart}${fractionalPart}`); - let denominator = 10n ** BigInt(fractionalPart.length); - const exponent = Number(exponentText ?? "0"); + let spotPrice = "0"; + let drained = pool.reserves.length < 2; - if (exponent > 0) { - numerator *= 10n ** BigInt(exponent); - } else if (exponent < 0) { - denominator *= 10n ** BigInt(-exponent); + if (pool.reserves.length >= 2) { + const reserveA = BigInt(pool.reserves[0]!.amount); + const reserveB = BigInt(pool.reserves[1]!.amount); + if (reserveA <= 0n || reserveB <= 0n) { + drained = true; + } else { + spotPrice = computeSpotPrice(reserveA, reserveB); + } } - return amount * denominator > reserve * numerator; + return { + poolId: pool.id, + reserves, + totalShares: pool.totalShares, + spotPrice, + drained, + }; } +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + function computeSpotPrice(reserveIn: bigint, reserveOut: bigint): string { - // spotPrice = reserveOut / reserveIn as a decimal string - if (reserveIn === 0n) return "0"; - return formatRatio(reserveOut, reserveIn); + if (reserveIn <= 0n) { + return "0"; + } + // Scale by 1e7 to preserve precision, then format as decimal string. + const scaled = (reserveOut * 10000000n) / reserveIn; + return formatScaled(scaled, 7); } function computeEffectivePrice(outputAmount: bigint, inputAmount: bigint): string { - if (inputAmount === 0n) return "0"; - return formatRatio(outputAmount, inputAmount); + if (inputAmount <= 0n) { + return "0"; + } + const scaled = (outputAmount * 10000000n) / inputAmount; + return formatScaled(scaled, 7); } function computePriceImpact(spotPrice: string, effectivePrice: string): string { - // Parse the decimal strings safely by treating them as fractional strings. - // Since formatRatio now outputs exact decimal strings, we parse them as - // pairs of (integer, fractional) parts for high precision. - const parseDecimal = (s: string): { int: bigint; frac: bigint; scale: bigint } => { - const dot = s.indexOf("."); - if (dot === -1) return { int: BigInt(s), frac: 0n, scale: 1n }; - const intPart = BigInt(s.slice(0, dot)); - const fracPartStr = s.slice(dot + 1).padEnd(12, "0"); - const scale = 10n ** BigInt(fracPartStr.length); - return { int: intPart, frac: BigInt(fracPartStr), scale }; - }; + const spot = parseScaled(spotPrice, 7); + const effective = parseScaled(effectivePrice, 7); + if (spot <= 0n) { + return "0.00"; + } + const diff = spot - effective; + if (diff <= 0n) { + return "0.00"; + } + // impact% = diff / spot * 100, scaled by 100 for two decimals. + const impactScaled = (diff * 10000n) / spot; + return formatScaled(impactScaled, 2); +} - const spot = parseDecimal(spotPrice); - const effective = parseDecimal(effectivePrice); - - const spotScaled = spot.int * spot.scale + spot.frac; - const effectiveScaled = effective.int * effective.scale + effective.frac; - const commonScale = spot.scale > effective.scale ? spot.scale : effective.scale; - const spotCommon = spotScaled * (commonScale / spot.scale); - const effectiveCommon = effectiveScaled * (commonScale / effective.scale); - - if (spotCommon === 0n) return "0.00"; - - // (spot - effective) / spot * 100 with 4 decimal places of precision - const SCALE = 10000n; - const numerator = (spotCommon - effectiveCommon) * SCALE * 100n; - const denominator = spotCommon; - - if (numerator <= 0n) return "0.00"; - - const result = numerator / denominator; - const intPart = result / SCALE; - const fracPart = result % SCALE; - const fracStr = fracPart.toString().padStart(4, "0").slice(0, 2); - return `${intPart}.${fracStr}`; +function computeOwnershipPercent(owned: bigint, totalShares: bigint): string { + if (totalShares <= 0n) { + return "0.00"; + } + const scaled = (owned * 10000n) / totalShares; + return formatScaled(scaled, 2); } -function computeOwnershipPercent(owned: bigint, total: bigint): string { - if (total === 0n) return "0.00"; - // (owned / total) * 100 with 2 decimal places using BigInt - const SCALE = 10000n; // 100 * 100 for 2 decimal places - const scaled = (owned * SCALE * 100n) / total; - const intPart = scaled / SCALE; - const fracPart = scaled % SCALE; - const fracStr = fracPart.toString().padStart(4, "0").slice(0, 2); - return `${intPart}.${fracStr}`; +function ratioExceedsLimit(amountIn: bigint, reserveIn: bigint, maxRatio: number): boolean { + if (reserveIn <= 0n) { + return true; + } + // Compare amountIn / reserveIn > maxRatio without floating point on BigInts. + const scaledRatio = BigInt(Math.round(maxRatio * 1000000)); + return amountIn * 1000000n > reserveIn * scaledRatio; +} + +function formatScaled(value: bigint, decimals: number): string { + const negative = value < 0n; + const abs = negative ? -value : value; + const factor = 10n ** BigInt(decimals); + const whole = abs / factor; + const frac = abs % factor; + const fracStr = frac.toString().padStart(decimals, "0"); + const result = decimals > 0 ? `${whole}.${fracStr}` : whole.toString(); + return negative ? `-${result}` : result; } -function formatRatio(numerator: bigint, denominator: bigint): string { - if (denominator === 0n) return "0"; - const SCALE = 10n ** 12n; - const scaled = (numerator * SCALE) / denominator; - const intPart = scaled / SCALE; - const fracPart = scaled % SCALE; - const fracStr = fracPart.toString().padStart(12, "0").replace(/0+$/, ""); - return fracStr.length > 0 ? `${intPart}.${fracStr}` : intPart.toString(); +function parseScaled(value: string, decimals: number): bigint { + const negative = value.startsWith("-"); + const clean = negative ? value.slice(1) : value; + const [whole, frac = ""] = clean.split("."); + const fracPadded = frac.padEnd(decimals, "0").slice(0, decimals); + const scaled = BigInt(whole || "0") * 10n ** BigInt(decimals) + BigInt(fracPadded || "0"); + return negative ? -scaled : scaled; }