diff --git a/src/fee-estimator.ts b/src/fee-estimator.ts index 0a078b7e..236b3cb8 100644 --- a/src/fee-estimator.ts +++ b/src/fee-estimator.ts @@ -7,12 +7,17 @@ export interface FeeEstimatorOptions { minRefetchIntervalMs?: number; } +export interface FeeEstimateOptions { + onError?: (error: Error) => void; +} + export class FeeEstimator { private baseFee: number; private isEstimating: boolean = false; private currentPromise: Promise | null = null; - private lastFetchTimestamp: number = 0; private readonly minRefetchIntervalMs: number; + private lastSuccessfulFetchAtValue: number | null = null; + private lastErrorValue: Error | null = null; constructor(initialFee: number = 100, options?: FeeEstimatorOptions) { this.baseFee = initialFee; @@ -27,14 +32,17 @@ export class FeeEstimator { * If `minRefetchIntervalMs` was configured, returns the cached `baseFee` when * called within that window after the last successful fetch. */ - async estimateFee(networkFetcher: () => Promise): Promise { + async estimateFee( + networkFetcher: () => Promise, + options: FeeEstimateOptions = {} + ): Promise { if (this.currentPromise) { return this.currentPromise; } // Return cached fee if within the minimum re-fetch interval if (this.minRefetchIntervalMs > 0) { - const elapsed = Date.now() - this.lastFetchTimestamp; + const elapsed = Date.now() - (this.lastSuccessfulFetchAtValue ?? 0); if (elapsed < this.minRefetchIntervalMs) { return this.baseFee; } @@ -52,9 +60,14 @@ export class FeeEstimator { // Round to 7 decimal places for precision handling this.baseFee = Math.round(rawFee * 10000000) / 10000000; - this.lastFetchTimestamp = Date.now(); + this.lastSuccessfulFetchAtValue = Date.now(); + this.lastErrorValue = null; return this.baseFee; } catch (error) { + const normalizedError = error instanceof Error ? error : new Error(String(error)); + this.lastErrorValue = normalizedError; + options.onError?.(normalizedError); + // Fallback sequence: return the last known base fee return this.baseFee; } finally { @@ -74,4 +87,16 @@ export class FeeEstimator { getBaseFee(): number { return this.baseFee; } + + get lastSuccessfulFetchAt(): number | null { + return this.lastSuccessfulFetchAtValue; + } + + get lastError(): Error | null { + return this.lastErrorValue; + } + + get isStale(): boolean { + return this.lastErrorValue !== null; + } } diff --git a/src/index.ts b/src/index.ts index 469ff1d0..964f06d5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,6 +16,7 @@ export type { ConduitContract } from './errors.js'; export * from './types/index.js'; export * from './adapters/index.js'; export { FeeEstimator } from './fee-estimator.js'; +export type { FeeEstimateOptions } from './fee-estimator.js'; export { WebSocketRelayer } from './relayer/WebSocketRelayer.js'; export { ErrorMapper } from './relayer/ErrorMapper.js'; export type { MappedErrorHandler } from './relayer/ErrorMapper.js'; diff --git a/src/tests/fee-estimator.test.ts b/src/tests/fee-estimator.test.ts index 457fb908..4af85869 100644 --- a/src/tests/fee-estimator.test.ts +++ b/src/tests/fee-estimator.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { FeeEstimator } from '../fee-estimator.js'; describe('FeeEstimator - Race condition and edge cases', () => { @@ -31,31 +31,46 @@ describe('FeeEstimator - Race condition and edge cases', () => { }); expect(estimator.getBaseFee()).toBe(150.1234568); + expect(estimator.lastSuccessfulFetchAt).toEqual(expect.any(Number)); + expect(estimator.lastError).toBeNull(); + expect(estimator.isStale).toBe(false); }); - it('should execute the fallback sequence when network fails', async () => { + it('should surface the specific error when falling back after network failure', async () => { const estimator = new FeeEstimator(100); + const onError = vi.fn(); const failingFetcher = async () => { throw new Error('Network error'); }; - const fee = await estimator.estimateFee(failingFetcher); + const fee = await estimator.estimateFee(failingFetcher, { onError }); // Fallback should return the original base fee expect(fee).toBe(100); + expect(onError).toHaveBeenCalledTimes(1); + expect(onError.mock.calls[0]![0]).toBeInstanceOf(Error); + expect(onError.mock.calls[0]![0].message).toBe('Network error'); + expect(estimator.lastError?.message).toBe('Network error'); + expect(estimator.lastSuccessfulFetchAt).toBeNull(); + expect(estimator.isStale).toBe(true); }); - it('should handle floating-point precision properly with invalid math', async () => { + it('should expose stale state when invalid math falls back', async () => { const estimator = new FeeEstimator(100); + const onError = vi.fn(); const badMathFetcher = async () => { return NaN; }; - const fee = await estimator.estimateFee(badMathFetcher); + const fee = await estimator.estimateFee(badMathFetcher, { onError }); // Boundary checks should catch this and fallback expect(fee).toBe(100); + expect(onError).toHaveBeenCalledTimes(1); + expect(onError.mock.calls[0]![0].message).toBe('Invalid network fee response'); + expect(estimator.lastError?.message).toBe('Invalid network fee response'); + expect(estimator.isStale).toBe(true); }); it('should handle multiple sequential requests successfully', async () => { @@ -69,5 +84,22 @@ describe('FeeEstimator - Race condition and edge cases', () => { const fee2 = await estimator.estimateFee(fetcher2); expect(fee2).toBe(130.5); + expect(estimator.isStale).toBe(false); + }); + + it('should clear stale status after a later successful fetch', async () => { + const estimator = new FeeEstimator(100); + + await estimator.estimateFee(async () => { + throw new Error('temporary outage'); + }); + expect(estimator.isStale).toBe(true); + expect(estimator.lastError?.message).toBe('temporary outage'); + + const recoveredFee = await estimator.estimateFee(async () => 140.25); + expect(recoveredFee).toBe(140.25); + expect(estimator.lastError).toBeNull(); + expect(estimator.lastSuccessfulFetchAt).toEqual(expect.any(Number)); + expect(estimator.isStale).toBe(false); }); });