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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 29 additions & 4 deletions src/fee-estimator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number> | 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;
Expand All @@ -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<number>): Promise<number> {
async estimateFee(
networkFetcher: () => Promise<number>,
options: FeeEstimateOptions = {}
): Promise<number> {
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;
}
Expand All @@ -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 {
Expand All @@ -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;
}
}
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
42 changes: 37 additions & 5 deletions src/tests/fee-estimator.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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);
});
});
Loading