From 4d1317ee881692f8042a7b991cf5f37990f23c43 Mon Sep 17 00:00:00 2001 From: AbuTuraab Date: Wed, 29 Apr 2026 08:56:58 +0100 Subject: [PATCH 1/2] feat: add gasestimator helper for soroban transactions --- .../sdk/src/__tests__/GasEstimator.test.ts | 128 +++++++ packages/sdk/src/index.ts | 1 + packages/sdk/src/utils/GasEstimator.ts | 312 ++++++++++++++++++ 3 files changed, 441 insertions(+) create mode 100644 packages/sdk/src/__tests__/GasEstimator.test.ts create mode 100644 packages/sdk/src/utils/GasEstimator.ts diff --git a/packages/sdk/src/__tests__/GasEstimator.test.ts b/packages/sdk/src/__tests__/GasEstimator.test.ts new file mode 100644 index 0000000..5b46ef6 --- /dev/null +++ b/packages/sdk/src/__tests__/GasEstimator.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it, vi } from "vitest"; +import { + GasEstimator, + estimateSorobanGas, + type GasEstimatorRpc, +} from "../utils/GasEstimator"; + +function createRpc(overrides: Partial = {}): GasEstimatorRpc { + return { + simulateTransaction: vi.fn().mockResolvedValue({ + minResourceFee: "1000", + }), + getFeeStats: vi.fn().mockResolvedValue({ + inclusionFee: { + p50: "100", + p90: "120", + p95: "140", + p99: "200", + transactionCount: "5", + }, + }), + ...overrides, + } as GasEstimatorRpc; +} + +describe("GasEstimator", () => { + it("combines simulation resource fees with low congestion fee stats", async () => { + const rpc = createRpc(); + const estimator = new GasEstimator({ rpc }); + + const estimate = await estimator.estimate({} as any); + + expect(rpc.simulateTransaction).toHaveBeenCalled(); + expect(rpc.getFeeStats).toHaveBeenCalled(); + expect(estimate.minResourceFee).toBe("1000"); + expect(estimate.resourceFee).toBe("1200"); + expect(estimate.inclusionFee).toBe("110"); + expect(estimate.recommendedFee).toBe("1310"); + expect(estimate.congestionLevel).toBe("low"); + }); + + it("uses higher percentiles and a larger buffer during high congestion", async () => { + const rpc = createRpc({ + getFeeStats: vi.fn().mockResolvedValue({ + inclusionFee: { + p50: "200", + p90: "450", + p95: "700", + p99: "1200", + transactionCount: "520", + }, + }), + }); + const estimator = new GasEstimator({ rpc }); + + const estimate = await estimator.estimate({} as any); + + expect(estimate.congestionLevel).toBe("high"); + expect(estimate.inclusionFee).toBe("945"); + expect(estimate.recommendedFee).toBe("2145"); + }); + + it("falls back to the configured base fee when fee stats are unavailable", async () => { + const rpc = createRpc({ + getFeeStats: vi.fn().mockRejectedValue(new Error("method not found")), + }); + const estimator = new GasEstimator({ rpc, baseFee: "250" }); + + const estimate = await estimator.estimate({} as any); + + expect(estimate.congestionLevel).toBe("unknown"); + expect(estimate.inclusionFee).toBe("275"); + expect(estimate.recommendedFee).toBe("1475"); + }); + + it("buffers resource limits parsed from Soroban transaction data", async () => { + const resources = { + instructions: () => 10, + readBytes: () => 20, + writeBytes: () => 30, + footprint: () => ({ + readOnly: () => ["a", "b"], + readWrite: () => ["c"], + }), + }; + const transactionData = { + resources: () => resources, + }; + const rpc = createRpc({ + simulateTransaction: vi.fn().mockResolvedValue({ + minResourceFee: "1000", + transactionData, + }), + }); + const estimator = new GasEstimator({ rpc, resourceBuffer: 1.5 }); + + const estimate = await estimator.estimate({} as any); + + expect(estimate.resourceLimits).toEqual({ + instructions: 15, + readBytes: 30, + writeBytes: 45, + readEntries: 3, + writeEntries: 2, + }); + }); + + it("throws when simulation returns an error response", async () => { + const rpc = createRpc({ + simulateTransaction: vi + .fn() + .mockResolvedValue({ error: "host function failed" }), + }); + const estimator = new GasEstimator({ rpc }); + + await expect(estimator.estimate({} as any)).rejects.toThrow( + "Gas estimation simulation failed: host function failed" + ); + }); + + it("provides a function helper for one-off estimates", async () => { + const rpc = createRpc(); + + const estimate = await estimateSorobanGas({} as any, { rpc }); + + expect(estimate.recommendedFee).toBe("1310"); + }); +}); diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 3a21f8d..ef2ae82 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -37,6 +37,7 @@ export * from "./utils/networkDetection"; export * from "./utils/streamHistory"; export * from "./utils/BalanceWatcher"; export * from "./utils/transactions"; +export * from "./utils/GasEstimator"; // Export error handling utilities export { diff --git a/packages/sdk/src/utils/GasEstimator.ts b/packages/sdk/src/utils/GasEstimator.ts new file mode 100644 index 0000000..ad37f81 --- /dev/null +++ b/packages/sdk/src/utils/GasEstimator.ts @@ -0,0 +1,312 @@ +import { xdr } from "@stellar/stellar-sdk"; +import { Server, Api } from "@stellar/stellar-sdk/rpc"; + +const DEFAULT_BASE_FEE = "100"; +const DEFAULT_RESOURCE_BUFFER = 1.2; +const DEFAULT_CONGESTION_BUFFER = 1.1; +const DEFAULT_HIGH_CONGESTION_BUFFER = 1.35; + +type SimulatableTransaction = Parameters[0]; + +export interface GasEstimatorRpc { + simulateTransaction( + tx: SimulatableTransaction + ): ReturnType; + getFeeStats?: () => Promise; +} + +export interface GasEstimatorOptions { + /** Existing Soroban RPC server. When omitted, `rpcUrl` is required. */ + rpc?: GasEstimatorRpc; + /** Soroban RPC endpoint URL. */ + rpcUrl?: string; + /** Base Stellar inclusion fee in stroops. Defaults to 100. */ + baseFee?: string; + /** Multiplier applied to simulated Soroban resource limits. Defaults to 1.2. */ + resourceBuffer?: number; + /** Multiplier applied to fee recommendations under normal congestion. Defaults to 1.1. */ + congestionBuffer?: number; + /** Multiplier applied to fee recommendations under high/severe congestion. Defaults to 1.35. */ + highCongestionBuffer?: number; +} + +export type CongestionLevel = + | "low" + | "moderate" + | "high" + | "severe" + | "unknown"; + +export interface GasResourceLimits { + instructions: number; + readBytes: number; + writeBytes: number; + readEntries: number; + writeEntries: number; +} + +export interface GasPriceRecommendation { + /** Recommended inclusion fee in stroops before Soroban resource fees. */ + inclusionFee: string; + /** Network congestion estimate derived from RPC fee statistics. */ + congestionLevel: CongestionLevel; + /** Raw fee stats returned by the RPC server, when available. */ + feeStats?: unknown; +} + +export interface GasEstimate { + /** Minimum Soroban resource fee returned by simulation. */ + minResourceFee: string; + /** Buffered Soroban resource fee recommendation. */ + resourceFee: string; + /** Recommended inclusion fee based on recent network congestion. */ + inclusionFee: string; + /** Total recommended transaction fee in stroops. */ + recommendedFee: string; + /** Buffered resource limits derived from simulation results. */ + resourceLimits: GasResourceLimits; + /** Raw simulation response for advanced callers. */ + simulation: Api.SimulateTransactionSuccessResponse; + /** Congestion estimate used to choose the recommended fee. */ + congestionLevel: CongestionLevel; + /** Raw fee stats returned by the RPC server, when available. */ + feeStats?: unknown; +} + +/** + * Estimates Soroban transaction fees and resource limits from simulation data + * plus current RPC fee statistics when available. + */ +export class GasEstimator { + private readonly rpc: GasEstimatorRpc; + private readonly baseFee: string; + private readonly resourceBuffer: number; + private readonly congestionBuffer: number; + private readonly highCongestionBuffer: number; + + constructor(options: GasEstimatorOptions) { + if (!options.rpc && !options.rpcUrl) { + throw new Error("GasEstimator requires either rpc or rpcUrl"); + } + + this.rpc = options.rpc ?? new Server(options.rpcUrl!, { allowHttp: true }); + this.baseFee = options.baseFee ?? DEFAULT_BASE_FEE; + this.resourceBuffer = options.resourceBuffer ?? DEFAULT_RESOURCE_BUFFER; + this.congestionBuffer = + options.congestionBuffer ?? DEFAULT_CONGESTION_BUFFER; + this.highCongestionBuffer = + options.highCongestionBuffer ?? DEFAULT_HIGH_CONGESTION_BUFFER; + } + + async estimate(tx: SimulatableTransaction): Promise { + const simulation = await this.rpc.simulateTransaction(tx); + + if (Api.isSimulationError(simulation)) { + throw new Error(`Gas estimation simulation failed: ${simulation.error}`); + } + + const success = simulation as Api.SimulateTransactionSuccessResponse; + const minResourceFee = success.minResourceFee ?? "0"; + const gasPrice = await this.estimateGasPrice(); + const feeBuffer = + gasPrice.congestionLevel === "high" || + gasPrice.congestionLevel === "severe" + ? this.highCongestionBuffer + : this.congestionBuffer; + + const resourceFee = multiplyStroops(minResourceFee, this.resourceBuffer); + const inclusionFee = multiplyStroops(gasPrice.inclusionFee, feeBuffer); + const recommendedFee = addStroops(resourceFee, inclusionFee); + + return { + minResourceFee, + resourceFee, + inclusionFee, + recommendedFee, + resourceLimits: bufferResourceLimits( + extractResourceLimits(success), + this.resourceBuffer + ), + simulation: success, + congestionLevel: gasPrice.congestionLevel, + feeStats: gasPrice.feeStats, + }; + } + + async estimateGasPrice(): Promise { + const feeStats = await this.fetchFeeStats(); + const inclusionFeeStats = extractInclusionFeeStats(feeStats); + + if (!inclusionFeeStats) { + return { + inclusionFee: this.baseFee, + congestionLevel: "unknown", + feeStats, + }; + } + + const congestionLevel = classifyCongestion( + inclusionFeeStats, + Number(this.baseFee) + ); + const percentile = + congestionLevel === "severe" + ? inclusionFeeStats.p99 + : congestionLevel === "high" + ? inclusionFeeStats.p95 + : congestionLevel === "moderate" + ? inclusionFeeStats.p90 + : inclusionFeeStats.p50; + + return { + inclusionFee: String(Math.max(Number(this.baseFee), percentile)), + congestionLevel, + feeStats, + }; + } + + private async fetchFeeStats(): Promise { + if (!this.rpc.getFeeStats) return undefined; + + try { + return await this.rpc.getFeeStats(); + } catch { + return undefined; + } + } +} + +export async function estimateSorobanGas( + tx: SimulatableTransaction, + options: GasEstimatorOptions +): Promise { + return new GasEstimator(options).estimate(tx); +} + +function extractResourceLimits( + simulation: Api.SimulateTransactionSuccessResponse +): GasResourceLimits { + const transactionData = simulation.transactionData; + + if (!transactionData) { + return emptyResourceLimits(); + } + + try { + const data = + typeof transactionData === "string" + ? xdr.SorobanTransactionData.fromXDR(transactionData, "base64") + : transactionData; + const resources = (data as xdr.SorobanTransactionData).resources(); + + return { + instructions: resources.instructions(), + readBytes: resources.readBytes(), + writeBytes: resources.writeBytes(), + readEntries: resources.footprint().readOnly().length, + writeEntries: resources.footprint().readWrite().length, + }; + } catch { + return emptyResourceLimits(); + } +} + +function bufferResourceLimits( + limits: GasResourceLimits, + multiplier: number +): GasResourceLimits { + return { + instructions: Math.ceil(limits.instructions * multiplier), + readBytes: Math.ceil(limits.readBytes * multiplier), + writeBytes: Math.ceil(limits.writeBytes * multiplier), + readEntries: Math.ceil(limits.readEntries * multiplier), + writeEntries: Math.ceil(limits.writeEntries * multiplier), + }; +} + +function emptyResourceLimits(): GasResourceLimits { + return { + instructions: 0, + readBytes: 0, + writeBytes: 0, + readEntries: 0, + writeEntries: 0, + }; +} + +function extractInclusionFeeStats(feeStats: unknown): + | { + p50: number; + p90: number; + p95: number; + p99: number; + transactionCount: number; + } + | undefined { + if (!isRecord(feeStats)) return undefined; + + const stats = isRecord(feeStats.inclusionFee) + ? feeStats.inclusionFee + : isRecord(feeStats.sorobanInclusionFee) + ? feeStats.sorobanInclusionFee + : feeStats; + + const p50 = readNumber(stats.p50); + const p90 = readNumber(stats.p90); + const p95 = readNumber(stats.p95); + const p99 = readNumber(stats.p99); + + if ([p50, p90, p95, p99].some((value) => value === undefined)) { + return undefined; + } + + return { + p50: p50!, + p90: p90!, + p95: p95!, + p99: p99!, + transactionCount: readNumber(stats.transactionCount) ?? 0, + }; +} + +function classifyCongestion( + stats: { + p50: number; + p90: number; + p95: number; + p99: number; + transactionCount: number; + }, + baseFee: number +): CongestionLevel { + const safeBaseFee = Math.max(baseFee, 1); + const p95Ratio = stats.p95 / safeBaseFee; + + if (stats.p99 >= safeBaseFee * 20 || p95Ratio >= 10) return "severe"; + if (stats.p95 >= safeBaseFee * 5 || stats.transactionCount >= 500) + return "high"; + if (stats.p90 >= safeBaseFee * 2 || stats.transactionCount >= 100) + return "moderate"; + return "low"; +} + +function multiplyStroops(value: string, multiplier: number): string { + return String(Math.ceil(Number(value) * multiplier - 1e-9)); +} + +function addStroops(left: string, right: string): string { + return String(BigInt(left) + BigInt(right)); +} + +function readNumber(value: unknown): number | undefined { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string" && value.trim() !== "") { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; + } + return undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} From 090b649ec32ba58d0f794a45b436051764267d22 Mon Sep 17 00:00:00 2001 From: AbuTuraab Date: Wed, 29 Apr 2026 10:51:10 +0100 Subject: [PATCH 2/2] Update packages/sdk/src/utils/GasEstimator.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- packages/sdk/src/utils/GasEstimator.ts | 36 +++++++++++++++++++++----- 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/packages/sdk/src/utils/GasEstimator.ts b/packages/sdk/src/utils/GasEstimator.ts index ad37f81..f6564ba 100644 --- a/packages/sdk/src/utils/GasEstimator.ts +++ b/packages/sdk/src/utils/GasEstimator.ts @@ -84,18 +84,42 @@ export class GasEstimator { private readonly congestionBuffer: number; private readonly highCongestionBuffer: number; +function assertStroopString(value: string, name: string): string { + if (!/^\d+$/.test(value)) { + throw new Error(`${name} must be a non-negative integer string in stroops`); + } + return value; +} + +function assertFinitePositiveNumber(value: number, name: string): number { + if (!Number.isFinite(value) || value <= 0) { + throw new Error(`${name} must be a finite number greater than 0`); + } + return value; +} + constructor(options: GasEstimatorOptions) { if (!options.rpc && !options.rpcUrl) { throw new Error("GasEstimator requires either rpc or rpcUrl"); } this.rpc = options.rpc ?? new Server(options.rpcUrl!, { allowHttp: true }); - this.baseFee = options.baseFee ?? DEFAULT_BASE_FEE; - this.resourceBuffer = options.resourceBuffer ?? DEFAULT_RESOURCE_BUFFER; - this.congestionBuffer = - options.congestionBuffer ?? DEFAULT_CONGESTION_BUFFER; - this.highCongestionBuffer = - options.highCongestionBuffer ?? DEFAULT_HIGH_CONGESTION_BUFFER; + this.baseFee = assertStroopString( + options.baseFee ?? DEFAULT_BASE_FEE, + "baseFee" + ); + this.resourceBuffer = assertFinitePositiveNumber( + options.resourceBuffer ?? DEFAULT_RESOURCE_BUFFER, + "resourceBuffer" + ); + this.congestionBuffer = assertFinitePositiveNumber( + options.congestionBuffer ?? DEFAULT_CONGESTION_BUFFER, + "congestionBuffer" + ); + this.highCongestionBuffer = assertFinitePositiveNumber( + options.highCongestionBuffer ?? DEFAULT_HIGH_CONGESTION_BUFFER, + "highCongestionBuffer" + ); } async estimate(tx: SimulatableTransaction): Promise {