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; } diff --git a/src/analyticsDashboardExporter.ts b/src/analyticsDashboardExporter.ts index b5e8d72..23eeae4 100644 --- a/src/analyticsDashboardExporter.ts +++ b/src/analyticsDashboardExporter.ts @@ -95,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[], @@ -180,6 +299,7 @@ export interface AnalyticsStreamStats { */ export class AnalyticsDashboardExporter { readonly events = new AnalyticsExportEventEmitter(); + readonly metrics = new MetricsCollector(); on( event: K, @@ -213,12 +333,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, @@ -229,6 +353,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 7d8fa53..4c1cac4 100644 --- a/src/analyticsTracking.ts +++ b/src/analyticsTracking.ts @@ -193,6 +193,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; @@ -352,12 +377,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) { 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; +} 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; + } + } +}