Skip to content
Draft
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
35 changes: 35 additions & 0 deletions src/feeEstimator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ import {
rpc as SorobanRpc,
BASE_FEE,
} from "@stellar/stellar-sdk";
import { SdkError, SdkErrorCode } from "./errors.js";
import type { FeeStats } from "./types.js";

export type { FeeStats } from "./types.js";

export interface FeeEstimate {
baseFee: string;
Expand Down Expand Up @@ -55,6 +59,37 @@ export function estimateFee(type: string, params: unknown): number {
return strategy.estimate(params);
}

/**
* Estimates transaction fee for a given payment amount based on fee statistics.
*
* @param amount - Payment amount in stroops (must be non-negative)
* @param feeStats - Fee statistics including baseFee, p50Fee, and p99Fee
* @returns Object with feeLumens (bigint), feePercent (number), and totalWithFee (bigint)
* @throws {SdkError} with code `INVALID_RECIPIENT` if `amount` is negative
*/
export function estimateFeeForAmount(
amount: bigint,
feeStats: FeeStats,
): { feeLumens: bigint; feePercent: number; totalWithFee: bigint } {
if (amount < 0n) {
throw new SdkError(
"Payment amount cannot be negative",
SdkErrorCode.INVALID_RECIPIENT,
{ amount },
);
}

const feeLumens = feeStats.baseFee;
const totalWithFee = amount + feeLumens;
const feePercent = amount === 0n ? 0 : (Number(feeLumens) / Number(amount)) * 100;

return {
feeLumens,
feePercent,
totalWithFee,
};
}

/**
* Estimate operation cost by simulating it.
*
Expand Down
5 changes: 4 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,7 @@ export type {
PreviewTokenSwapResult,
SDKHealth,
FeeBreakdown,
FeeStats,
TokenInfo,
ExpiryEvent,
ExpiryCallback,
Expand Down Expand Up @@ -1038,11 +1039,13 @@ export type {
} from "./xdrParser.js";

// ---------------------------------------------------------------------------
// Fee surge detector
// Fee surge detector & Fee Estimator
// ---------------------------------------------------------------------------

export { detectFeeSurge, clearFeeSurgeCache } from "./feeSurgeDetector.js";
export type { FeeSurgeConfig, FeeRecommendation, CongestionLevel } from "./feeSurgeDetector.js";
export { estimateFee, estimateFeeForAmount, estimateOperationCost } from "./feeEstimator.js";
export type { FeeEstimate, FeeEstimateError, FeeEstimationStrategy } from "./feeEstimator.js";

export {
reconcileChannel,
Expand Down
7 changes: 7 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -653,6 +653,13 @@ export interface FeeBreakdown {
feeBps: number;
}

/** Fee statistics for network operations. */
export interface FeeStats {
baseFee: bigint;
p50Fee: bigint;
p99Fee: bigint;
}

/** Token metadata information. */
export interface TokenInfo {
/** Token contract address. */
Expand Down
81 changes: 45 additions & 36 deletions test/feeEstimator.test.ts
Original file line number Diff line number Diff line change
@@ -1,49 +1,58 @@
import { describe, expect, it, vi } from "vitest";
import { estimateFee, estimateOperationCost, type FeeEstimate } from "../src/feeEstimator.js";
import {
estimateFee,
estimateOperationCost,
estimateFeeForAmount,
type FeeEstimate,
type FeeStats,
} from "../src/feeEstimator.js";
import { SdkError, SdkErrorCode } from "../src/errors.js";
import { rpc as SorobanRpc, BASE_FEE, Operation, Asset } from "@stellar/stellar-sdk";

describe("estimateFeeForAmount", () => {
it("returns the base fee when feeBps is zero", () => {
expect(estimateFeeForAmount(0n, { baseFee: BASE_FEE })).toBe(BASE_FEE);
expect(estimateFeeForAmount(123_456_789n, { baseFee: BASE_FEE })).toBe(BASE_FEE);
const sampleFeeStats: FeeStats = {
baseFee: 100n,
p50Fee: 150n,
p99Fee: 300n,
};

it("calculates correct fee for known amount and feeStats", () => {
const result = estimateFeeForAmount(1000n, sampleFeeStats);
expect(result.feeLumens).toBe(100n);
expect(result.totalWithFee).toBe(1100n);
expect(result.feePercent).toBe(10);
});

it("computes an exact fee for a whole-number bps using bigint", () => {
// 100 bps = 1% on 10_000_000 stroops (10 XLM) = 100_000 stroops.
const fee = estimateFeeForAmount(10_000_000n, { feeBps: 100, baseFee: 0n });
expect(fee).toBe(100_000n);
it("calculates fee for large payment amounts with bigint precision", () => {
const stats: FeeStats = {
baseFee: 5000n,
p50Fee: 10000n,
p99Fee: 20000n,
};
const amount = 100_000n;
const result = estimateFeeForAmount(amount, stats);
expect(result.feeLumens).toBe(5000n);
expect(result.totalWithFee).toBe(105_000n);
expect(result.feePercent).toBe(5);
});

it("rounds up fractional bps to avoid undercharging", () => {
// 1 bps of 9 stroops = 0.0009 stroop -> rounds up to 1 stroop.
expect(estimateFeeForAmount(1_000n, { feeBps: 1, baseFee: 0n })).toBe(1n); // 0.1 stroop -> 1
// Exact multiples stay exact: 10_000 stroops * 1bps = 1 stroop.
expect(estimateFeeForAmount(10_000n, { feeBps: 1, baseFee: 0n })).toBe(1n);
it("zero amount returns 0 percent and preserves feeLumens and totalWithFee", () => {
const result = estimateFeeForAmount(0n, sampleFeeStats);
expect(result.feeLumens).toBe(100n);
expect(result.totalWithFee).toBe(100n);
expect(result.feePercent).toBe(0);
});

it("truncates instead of rounding up when roundUp is false", () => {
// 1 bps of 1000 stroops = 0.1 stroop -> truncated to 0.
expect(estimateFeeForAmount(1000n, { feeBps: 1, baseFee: 0n, roundUp: false })).toBe(0n);
});

it("adds the flat base fee on top of the proportional fee", () => {
const fee = estimateFeeForAmount(2000n, { feeBps: 50, baseFee: 1000n });
// proportional = 2000 * 50 / 10000 = 10; total = 1000 + 10 = 1010.
expect(fee).toBe(1010n);
});

it("handles large amounts without precision loss", () => {
const huge = 123456789123456789n;
const fee = estimateFeeForAmount(huge, { feeBps: 250, baseFee: BASE_FEE });
expect(fee).toBe(BASE_FEE + (huge * 250n) / 10000n);
});

it("throws on negative amount", () => {
expect(() => estimateFeeForAmount(-1n)).toThrow(RangeError);
});

it("throws on negative feeBps", () => {
expect(() => estimateFeeForAmount(1000n, { feeBps: -1 })).toThrow(RangeError);
it("throws SdkError with code INVALID_RECIPIENT on negative amount", () => {
expect(() => estimateFeeForAmount(-1n, sampleFeeStats)).toThrow(SdkError);
try {
estimateFeeForAmount(-100n, sampleFeeStats);
expect.unreachable("Should have thrown SdkError");
} catch (err) {
expect(err).toBeInstanceOf(SdkError);
expect((err as SdkError).code).toBe(SdkErrorCode.INVALID_RECIPIENT);
expect((err as SdkError).code).toBe("INVALID_RECIPIENT");
}
});
});

Expand Down