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
202 changes: 202 additions & 0 deletions src/tests/feeCalculator.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
import { describe, it, expect, vi } from "vitest";
import { FeeCalculator } from "../transaction/feeCalculator";
import type { FeeEstimate } from "../transaction/estimateFee";

const MOCK_ESTIMATE: FeeEstimate = {
fee: "250",
feeFloat: 250,
feeXlm: "0.0000250",
baseFee: "100",
simulated: true,
};

const MOCK_ESTIMATE_NO_SIM: FeeEstimate = {
fee: "100",
feeFloat: 100,
feeXlm: "0.0000100",
baseFee: "100",
simulated: false,
};

const MOCK_ESTIMATE_WITH_SURGE: FeeEstimate = {
fee: "5000",
feeFloat: 5000,
feeXlm: "0.0005000",
baseFee: "100",
simulated: true,
surge: true,
tiers: {
economy: "80",
standard: "100",
fast: "250",
},
};

describe("FeeCalculator", () => {
it("starts with null current breakdown", () => {
const fc = new FeeCalculator();
expect(fc.current).toBeNull();
});

it("computes breakdown from fee estimate", () => {
const fc = new FeeCalculator();
const breakdown = fc.computeFromEstimate(MOCK_ESTIMATE);
expect(breakdown.baseFee.stroops).toBe("100");
expect(breakdown.resourceFee.stroops).toBe("150");
expect(breakdown.total.stroops).toBe("250");
expect(breakdown.simulated).toBe(true);
expect(breakdown.surge).toBe(false);
});

it("computes resource fee as zero for non-simulated estimates", () => {
const fc = new FeeCalculator();
const breakdown = fc.computeFromEstimate(MOCK_ESTIMATE_NO_SIM);
expect(breakdown.resourceFee.stroops).toBe("0");
expect(breakdown.simulated).toBe(false);
});

it("detects fee surge", () => {
const fc = new FeeCalculator();
const breakdown = fc.computeFromEstimate(MOCK_ESTIMATE_WITH_SURGE);
expect(breakdown.surge).toBe(true);
});

it("computes fee tiers", () => {
const fc = new FeeCalculator();
const breakdown = fc.computeFromEstimate(MOCK_ESTIMATE_WITH_SURGE);
expect(breakdown.tiers).not.toBeNull();
if (breakdown.tiers) {
expect(breakdown.tiers.economy.stroops).toBe("80");
expect(breakdown.tiers.standard.stroops).toBe("100");
expect(breakdown.tiers.fast.stroops).toBe("250");
}
});

it("emits breakdown on compute", () => {
const listener = vi.fn();
const fc = new FeeCalculator({ onFeeUpdate: listener });
fc.computeFromEstimate(MOCK_ESTIMATE);
expect(listener).toHaveBeenCalledTimes(1);
expect(listener.mock.calls[0][0].total.stroops).toBe("250");
});

it("subscribe and unsubscribe works", () => {
const fc = new FeeCalculator();
const listener = vi.fn();
const unsub = fc.subscribe(listener);
fc.computeFromEstimate(MOCK_ESTIMATE);
expect(listener).toHaveBeenCalledTimes(1);
unsub();
fc.computeFromEstimate(MOCK_ESTIMATE);
expect(listener).toHaveBeenCalledTimes(1);
});

it("getComparison returns comparison items", () => {
const fc = new FeeCalculator();
const items = fc.getComparison(MOCK_ESTIMATE);
expect(items.length).toBe(3);
expect(items[0].label).toBe("Simple Payment");
expect(items[1].label).toBe("Contract Call");
expect(items[2].label).toBe("Fee Comparison");
});

it("getComparison includes surge info when applicable", () => {
const fc = new FeeCalculator();
const items = fc.getComparison(MOCK_ESTIMATE_WITH_SURGE);
expect(items[2].description).toContain("congestion");
});

it("getSurgeAlert returns null when no breakdown set", () => {
const fc = new FeeCalculator();
expect(fc.getSurgeAlert()).toBeNull();
});

it("getSurgeAlert returns null when no surge", () => {
const fc = new FeeCalculator();
fc.computeFromEstimate(MOCK_ESTIMATE);
expect(fc.getSurgeAlert()).toBeNull();
});

it("getSurgeAlert returns alert when surge detected", () => {
const fc = new FeeCalculator();
fc.computeFromEstimate(MOCK_ESTIMATE_WITH_SURGE);
const alert = fc.getSurgeAlert();
expect(alert).not.toBeNull();
if (alert) {
expect(alert.active).toBe(true);
expect(alert.message).toContain("surge");
expect(alert.recommendation).toContain("waiting");
}
});

it("formatFeeForDisplay returns formatted strings", () => {
const fc = new FeeCalculator();
const breakdown = fc.computeFromEstimate(MOCK_ESTIMATE);
const formatted = fc.formatFeeForDisplay(breakdown.total);
expect(formatted.stroops).toContain("stroops");
expect(formatted.xlm).toContain("XLM");
});

it("handleError returns fallback info", () => {
const fc = new FeeCalculator();
const result = fc.handleError(new Error("Network timeout"));
expect(result.status).toBe("ok");
if (result.status === "ok") {
expect(result.data.message).toContain("Network timeout");
expect(result.data.fallbackBaseFee.stroops).toBe("100");
expect(result.data.recommendation).toContain("fallback");
}
});

it("tooltip contents are informative", () => {
const fc = new FeeCalculator();
const breakdown = fc.computeFromEstimate(MOCK_ESTIMATE);
expect(breakdown.baseFee.tooltip).toContain("minimum fee");
expect(breakdown.resourceFee.tooltip).toContain("computation");
expect(breakdown.total.tooltip).toContain("sum of the base fee");
});

it("XLM conversion is correct", () => {
const fc = new FeeCalculator();
const breakdown = fc.computeFromEstimate(MOCK_ESTIMATE);
expect(breakdown.total.xlm).toBe("0.0000250");
});

it("tiers include tooltips", () => {
const fc = new FeeCalculator();
const breakdown = fc.computeFromEstimate(MOCK_ESTIMATE_WITH_SURGE);
if (breakdown.tiers) {
expect(breakdown.tiers.economy.tooltip).toContain("10th percentile");
expect(breakdown.tiers.standard.tooltip).toContain("50th percentile");
expect(breakdown.tiers.fast.tooltip).toContain("90th percentile");
}
});

it("handles zero fee edge case", () => {
const fc = new FeeCalculator();
const zeroEstimate: FeeEstimate = {
fee: "0",
feeFloat: 0,
feeXlm: "0.0000000",
baseFee: "0",
simulated: false,
};
const breakdown = fc.computeFromEstimate(zeroEstimate);
expect(breakdown.total.stroops).toBe("0");
expect(breakdown.resourceFee.stroops).toBe("0");
});

it("handles large fee values", () => {
const fc = new FeeCalculator();
const largeEstimate: FeeEstimate = {
fee: "100000000",
feeFloat: 100000000,
feeXlm: "10.0000000",
baseFee: "100",
simulated: true,
};
const breakdown = fc.computeFromEstimate(largeEstimate);
expect(breakdown.total.stroops).toBe("100000000");
expect(breakdown.resourceFee.stroops).toBe("99999900");
});
});
180 changes: 180 additions & 0 deletions src/transaction/feeCalculator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import type { SorokitResult } from "../shared/response";
import { ok, err, SorokitErrorCode } from "../shared/response";
import type { FeeEstimate, FeeEstimateInput, FeeEstimateOptions, FeeTiers } from "./estimateFee";

export interface FeeBreakdown {
baseFee: FeeComponent;
resourceFee: FeeComponent;
total: FeeComponent;
surge: boolean;
simulated: boolean;
tiers: FeeComponentTiers | null;
}

export interface FeeComponent {
stroops: string;
stroopsFloat: number;
xlm: string;
label: string;
tooltip: string;
}

export interface FeeComponentTiers {
economy: FeeComponent;
standard: FeeComponent;
fast: FeeComponent;
}

export interface FeeComparisonItem {
label: string;
description: string;
fee: FeeComponent;
}

export type FeeCalculatorListener = (breakdown: FeeBreakdown) => void;
export type FeeCalculatorUnsubscribe = () => void;

export interface FeeCalculatorConfig {
onFeeUpdate?: FeeCalculatorListener;
}

function stroopsToXlm(stroops: number): string {
return (stroops / 10_000_000).toFixed(7);
}

function toFeeComponent(stroops: number, label: string, tooltip: string): FeeComponent {
return {
stroops: String(stroops),
stroopsFloat: stroops,
xlm: stroopsToXlm(stroops),
label,
tooltip,
};
}

const BASE_FEE_TOOLTIP = "Base fee is the minimum fee required by the Stellar network. It applies to every transaction regardless of complexity.";
const RESOURCE_FEE_TOOLTIP = "Resource fee covers the cost of Soroban computation and storage. It varies based on contract complexity.";
const TOTAL_FEE_TOOLTIP = "Total fee is the sum of the base fee and any resource fee. This is the total amount deducted from your account.";

const SURGE_TOOLTIP = "A fee surge is detected when the estimated fee exceeds 2x the recent network median. This indicates network congestion.";

const SIMULATED_TOOLTIP = "This fee was estimated via Soroban RPC simulation, which provides a more accurate cost based on actual computation.";
const ESTIMATED_TOOLTIP = "This fee is an estimate based on the base fee. A simulation was not available.";

const ECONOMY_TOOLTIP = "Economy tier (10th percentile): suitable for non-urgent transactions during low network activity.";
const STANDARD_TOOLTIP = "Standard tier (50th percentile): the typical network fee for timely processing.";
const FAST_TOOLTIP = "Fast tier (90th percentile): prioritizes inclusion during network congestion.";

export class FeeCalculator {
private _currentBreakdown: FeeBreakdown | null = null;
private _listeners: Set<FeeCalculatorListener> = new Set();

constructor(config?: FeeCalculatorConfig) {
if (config?.onFeeUpdate) {
this._listeners.add(config.onFeeUpdate);
}
}

get current(): FeeBreakdown | null {
return this._currentBreakdown;
}

subscribe(listener: FeeCalculatorListener): FeeCalculatorUnsubscribe {
this._listeners.add(listener);
return () => { this._listeners.delete(listener); };
}

private _emit(): void {
if (this._currentBreakdown) {
for (const listener of this._listeners) {
listener(this._currentBreakdown);
}
}
}

computeFromEstimate(estimate: FeeEstimate): FeeBreakdown {
const totalStroops = parseInt(estimate.fee, 10);
const baseStroops = parseInt(estimate.baseFee, 10);
const resourceStroops = estimate.simulated ? Math.max(0, totalStroops - baseStroops) : 0;

const breakdown: FeeBreakdown = {
baseFee: toFeeComponent(baseStroops, "Base Fee", BASE_FEE_TOOLTIP),
resourceFee: toFeeComponent(resourceStroops, "Resource Fee", RESOURCE_FEE_TOOLTIP),
total: toFeeComponent(totalStroops, "Total Fee", TOTAL_FEE_TOOLTIP),
surge: estimate.surge ?? false,
simulated: estimate.simulated,
tiers: estimate.tiers ? this._computeTiers(estimate.tiers) : null,
};

this._currentBreakdown = breakdown;
this._emit();
return breakdown;
}

private _computeTiers(tiers: FeeTiers): FeeComponentTiers {
return {
economy: toFeeComponent(parseInt(tiers.economy, 10), "Economy", ECONOMY_TOOLTIP),
standard: toFeeComponent(parseInt(tiers.standard, 10), "Standard", STANDARD_TOOLTIP),
fast: toFeeComponent(parseInt(tiers.fast, 10), "Fast", FAST_TOOLTIP),
};
}

getComparison(estimate: FeeEstimate): FeeComparisonItem[] {
const totalStroops = parseInt(estimate.fee, 10);
const baseStroops = parseInt(estimate.baseFee, 10);

return [
{
label: "Simple Payment",
description: "Basic XLM or asset transfer between accounts",
fee: toFeeComponent(baseStroops, "Base Fee", BASE_FEE_TOOLTIP),
},
{
label: "Contract Call",
description: "Soroban smart contract invocation",
fee: toFeeComponent(totalStroops, "Estimated Fee", TOTAL_FEE_TOOLTIP),
},
{
label: "Fee Comparison",
description: estimate.surge
? "Current fee exceeds 2x the network median — possible congestion"
: "Fee is within normal network range",
fee: toFeeComponent(totalStroops, "Current vs Median", SURGE_TOOLTIP),
},
];
}

getSurgeAlert(): { active: boolean; message: string; recommendation: string } | null {
if (!this._currentBreakdown) return null;
if (!this._currentBreakdown.surge) return null;

return {
active: true,
message: "Network fee surge detected — fees are above the recent median.",
recommendation: "Consider waiting until network activity subsides, or use the Economy tier if your transaction is not time-sensitive.",
};
}

formatFeeForDisplay(component: FeeComponent): { stroops: string; xlm: string } {
return {
stroops: `${component.stroops} stroops`,
xlm: `${component.xlm} XLM`,
};
}

handleError(error: unknown): SorokitResult<FeeBreakdownFallback> {
const message = error instanceof Error ? error.message : String(error);
const fallback: FeeBreakdownFallback = {
message: `Unable to estimate fees: ${message}`,
fallbackBaseFee: toFeeComponent(100, "Base Fee (fallback)", BASE_FEE_TOOLTIP),
recommendation: "Check your network connection and try again. The base fee of 100 stroops is used as a fallback.",
};
return ok(fallback);
}
}

export interface FeeBreakdownFallback {
message: string;
fallbackBaseFee: FeeComponent;
recommendation: string;
}
Loading