From 1cef16fce19530717c906c571b60f1751d3704be Mon Sep 17 00:00:00 2001 From: Sorokit Dev Date: Sun, 26 Jul 2026 09:29:45 +0000 Subject: [PATCH] feat(transaction): add FeeCalculator for fee breakdown and comparison Implement FeeCalculator class that wraps the existing estimateFee function to provide structured fee breakdowns, comparisons, and surge detection. - FeeCalculator class with computeFromEstimate and listener support - FeeBreakdown with base fee, resource fee, total fee components - Tooltips explaining each fee component - Fee comparison for different transaction types - Surge detection alerts - Edge case handling (zero fees, network errors) - Comprehensive test coverage (19 tests) Closes #201 --- src/index.ts | 11 ++ src/tests/feeCalculator.test.ts | 202 +++++++++++++++++++++++++++++++ src/transaction/feeCalculator.ts | 180 +++++++++++++++++++++++++++ src/transaction/index.ts | 11 ++ 4 files changed, 404 insertions(+) create mode 100644 src/tests/feeCalculator.test.ts create mode 100644 src/transaction/feeCalculator.ts diff --git a/src/index.ts b/src/index.ts index b9e0f76..181ab9f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -111,6 +111,17 @@ export type { FeeTiers, } from "./transaction/estimateFee"; export { calculateFeeTiers } from "./transaction/estimateFee"; +export { FeeCalculator } from "./transaction"; +export type { + FeeBreakdown, + FeeComponent, + FeeComponentTiers, + FeeComparisonItem, + FeeCalculatorListener, + FeeCalculatorUnsubscribe, + FeeCalculatorConfig, + FeeBreakdownFallback, +} from "./transaction"; export { streamTransactions } from "./transaction/streamTransactions"; export { buildPathPayment, diff --git a/src/tests/feeCalculator.test.ts b/src/tests/feeCalculator.test.ts new file mode 100644 index 0000000..e0715a6 --- /dev/null +++ b/src/tests/feeCalculator.test.ts @@ -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"); + }); +}); diff --git a/src/transaction/feeCalculator.ts b/src/transaction/feeCalculator.ts new file mode 100644 index 0000000..e9ceb9b --- /dev/null +++ b/src/transaction/feeCalculator.ts @@ -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 = 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 { + 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; +} diff --git a/src/transaction/index.ts b/src/transaction/index.ts index d3159e1..dfe4062 100644 --- a/src/transaction/index.ts +++ b/src/transaction/index.ts @@ -146,6 +146,17 @@ export type { DestinationValidationResult, ValidateDestinationOptions, } from "./validateDestination"; +export { FeeCalculator } from "./feeCalculator"; +export type { + FeeBreakdown, + FeeComponent, + FeeComponentTiers, + FeeComparisonItem, + FeeCalculatorListener, + FeeCalculatorUnsubscribe, + FeeCalculatorConfig, + FeeBreakdownFallback, +} from "./feeCalculator"; // ─── Asset constants and factories ─────────────────────────────────────────── export const USDC_MAINNET_ISSUER = "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN";