From b7ccbf10172f5db446315568d0edd7df934fccd9 Mon Sep 17 00:00:00 2001 From: Menjay7 Date: Fri, 28 Aug 2026 00:20:51 +0100 Subject: [PATCH] Add bigint-precision fee estimation for amounts --- .vscode/settings.json | 2 -- src/feeEstimator.ts | 59 +++++++++++++++++++++++++++++++++++++++ test/feeEstimator.test.ts | 49 ++++++++++++++++++++++++++++++-- 3 files changed, 106 insertions(+), 4 deletions(-) delete mode 100644 .vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 7a73a41..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,2 +0,0 @@ -{ -} \ No newline at end of file diff --git a/src/feeEstimator.ts b/src/feeEstimator.ts index e104da9..73f6411 100644 --- a/src/feeEstimator.ts +++ b/src/feeEstimator.ts @@ -30,6 +30,65 @@ export interface FeeEstimateError { total: string; } +/** Options accepted by {@link estimateFeeForAmount}. */ +export interface AmountFeeEstimateOptions { + /** + * Protocol fee in basis points (100 bps = 1%). Applied proportionally to + * `amount`. Defaults to `0`. + */ + feeBps?: number; + + /** + * Flat base fee, in stroops, added on top of the proportional fee. + * Defaults to the Stellar network {@link BASE_FEE}. + */ + baseFee?: bigint; + + /** + * When `true` the proportional fee is rounded **up** to the nearest stroop + * so the estimate never undercharges due to fractional bps. Defaults to + * `true`. + */ + roundUp?: boolean; +} + +/** + * Estimate the fee for a given `amount` using exact `bigint` arithmetic. + * + * The fee is computed as: + * + * fee = baseFee + ceil(amount * feeBps / 10_000) + * + * with `roundUp` controlling whether the proportional term is rounded up or + * simply truncated. All arithmetic is performed on `bigint` stroops, so there + * is no floating-point rounding or precision loss. + * + * @param amount - Gross amount in stroops; must be non-negative. + * @param options - {@link AmountFeeEstimateOptions} controlling the fee. + * @returns The estimated fee in stroops, always a non-negative `bigint`. + * @throws {RangeError} if `amount` or `feeBps` is negative. + */ +export function estimateFeeForAmount( + amount: bigint, + options: AmountFeeEstimateOptions = {} +): bigint { + const { feeBps = 0, baseFee = BigInt(BASE_FEE), roundUp = true } = options; + + if (amount < 0n) { + throw new RangeError("amount must be non-negative"); + } + if (feeBps < 0) { + throw new RangeError("feeBps must be non-negative"); + } + + const proportionalBps = amount * BigInt(feeBps); + const proportional = roundUp + ? (proportionalBps + 9_999n) / 10_000n + : proportionalBps / 10_000n; + + return baseFee + proportional; +} + /** * Estimate operation cost by simulating it. * diff --git a/test/feeEstimator.test.ts b/test/feeEstimator.test.ts index e089391..649903a 100644 --- a/test/feeEstimator.test.ts +++ b/test/feeEstimator.test.ts @@ -1,8 +1,53 @@ import { describe, expect, it, vi } from "vitest"; -import { estimateOperationCost, type FeeEstimate } from "../src/feeEstimator.js"; +import { estimateOperationCost, estimateFeeForAmount, type FeeEstimate } from "../src/feeEstimator.js"; import { rpc as SorobanRpc, BASE_FEE, Operation, Asset } from "@stellar/stellar-sdk"; -describe("feeEstimator", () => { +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); + }); + + 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("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("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); + }); +}); + +describe("estimateOperationCost", () => { it("returns fee estimate with base and resource fees", async () => { const mockServer = { simulateTransaction: vi.fn().mockResolvedValue({