From 4b37438c35ae742527a2ebadc0ea068f68833eb1 Mon Sep 17 00:00:00 2001 From: PraiseD Date: Mon, 27 Jul 2026 21:35:47 +0100 Subject: [PATCH] feat(stellar): add estimateFee helper with Horizon fee_stats and urgency levels --- src/chains/stellar/fee.ts | 97 ++++++++++++++++++++++++++++++++ src/chains/stellar/index.ts | 26 ++++++++- test/chains/stellar/fee.test.ts | 98 +++++++++++++++++++++++++++++++++ 3 files changed, 218 insertions(+), 3 deletions(-) create mode 100644 src/chains/stellar/fee.ts create mode 100644 test/chains/stellar/fee.test.ts diff --git a/src/chains/stellar/fee.ts b/src/chains/stellar/fee.ts new file mode 100644 index 0000000..2e8ecfa --- /dev/null +++ b/src/chains/stellar/fee.ts @@ -0,0 +1,97 @@ +/** + * Fee estimation helpers for Stellar transactions. + * + * Wraith flows default to the minimum base fee (100 stroops), which can fail + * during network congestion. These helpers pull recent fee data from Horizon + * and return a suitable inclusion fee based on the desired urgency level. + * + * @example + * ```ts + * import { estimateFee } from "@wraith-protocol/sdk/chains/stellar"; + * + * const fee = await estimateFee("payment", "normal"); + * // pass to buildStealthPayment({ fee, ... }) + * ``` + */ + +/** Operation kinds that influence base fee sizing. */ +export type OpKind = 'payment' | 'create_account' | 'soroban'; + +/** + * How quickly you need the transaction included. + * + * - `low` — p10 of recent fee_charged; cheapest, may be slow under load + * - `normal` — p50 (median); good default for most flows + * - `high` — p90; near-certain inclusion even during moderate congestion + */ +export type FeeUrgency = 'low' | 'normal' | 'high'; + +const HORIZON_TESTNET = 'https://horizon-testnet.stellar.org'; +const MIN_FEE = 100; +const CACHE_TTL_MS = 10_000; + +interface HorizonFeeStats { + fee_charged: { + p10: string; + p50: string; + p90: string; + }; +} + +interface CacheEntry { + stats: HorizonFeeStats; + expiresAt: number; +} + +const cache = new Map(); + +async function fetchFeeStats(horizonUrl: string): Promise { + const entry = cache.get(horizonUrl); + if (entry && Date.now() < entry.expiresAt) return entry.stats; + + const res = await fetch(`${horizonUrl}/fee_stats`); + if (!res.ok) throw new Error(`Horizon fee_stats returned ${res.status}`); + const stats = (await res.json()) as HorizonFeeStats; + + cache.set(horizonUrl, { stats, expiresAt: Date.now() + CACHE_TTL_MS }); + return stats; +} + +function pickPercentile(stats: HorizonFeeStats, urgency: FeeUrgency): number { + const raw = + urgency === 'low' + ? stats.fee_charged.p10 + : urgency === 'high' + ? stats.fee_charged.p90 + : stats.fee_charged.p50; + + return Math.max(MIN_FEE, parseInt(raw, 10) || MIN_FEE); +} + +/** + * Estimates an inclusion fee in stroops for the given operation kind and urgency. + * + * Soroban operations carry a higher base multiplier (×10) because they consume + * more ledger resources than classic operations. + * + * @param opKind - The type of operation being submitted. + * @param urgency - How aggressively to price for inclusion. + * @param horizonUrl - Override the Horizon endpoint. Defaults to testnet. + * @returns Fee in stroops as a string (compatible with `TransactionBuilder`). + */ +export async function estimateFee( + opKind: OpKind, + urgency: FeeUrgency = 'normal', + horizonUrl = HORIZON_TESTNET, +): Promise { + const stats = await fetchFeeStats(horizonUrl); + const base = pickPercentile(stats, urgency); + + const fee = opKind === 'soroban' ? base * 10 : base; + return String(fee); +} + +/** Clears the in-memory fee stats cache. Useful in tests. */ +export function clearFeeCache(): void { + cache.clear(); +} diff --git a/src/chains/stellar/index.ts b/src/chains/stellar/index.ts index a8375a9..1db780d 100644 --- a/src/chains/stellar/index.ts +++ b/src/chains/stellar/index.ts @@ -43,9 +43,8 @@ export { L, } from './scalar'; export { bytesToHex, hexToBytes } from './utils'; - -export { MemoryCache, IndexedDBCache, autoSelectCache } from './cache'; -export type { AnnouncementCache } from './cache'; +export { estimateFee, clearFeeCache } from './fee'; +export type { OpKind, FeeUrgency } from './fee'; export { fetchAnnouncementsStream, @@ -54,6 +53,9 @@ export { } from './announcements'; export type { FetchAnnouncementsOptions } from './announcements'; +export { MemoryCache, IndexedDBCache, autoSelectCache } from './cache'; +export type { AnnouncementCache } from './cache'; + export { MAX_RPC_EVENT_FILTERS, encodeSymbolTopic, @@ -82,3 +84,21 @@ export type { export { buildStellarSwapAndStealth } from './swap'; export type { BuildStellarSwapAndStealthOptions, SwapAndStealthResult } from './swap'; + +export { buildPathStealthPayment, findStrictReceivePath } from './path-payment'; +export type { + BuildPathStealthPaymentOptions, + PathStealthPaymentResult, + FindStrictReceivePathOptions, + StrictReceivePathResult, +} from './path-payment'; + +export { encodeMemo, decodeMemo, extractMemoFromTransaction } from './memo'; +export type { MemoType, MemoValue, TypedMemo } from './memo'; +export { MemoValidationError, TEXT_MEMO_MAX_BYTES, HASH_MEMO_BYTES, ID_MEMO_MAX } from './memo'; + +export { getAssetMetadata, getAssetBalance, clearAssetMetadataCache } from './asset'; +export type { AssetMetadata, GetAssetMetadataOptions, GetAssetBalanceOptions } from './asset'; + +export { createHorizonClient } from './horizon'; +export type { RetryPolicy, HorizonClient, HorizonClientConfig } from './horizon'; diff --git a/test/chains/stellar/fee.test.ts b/test/chains/stellar/fee.test.ts new file mode 100644 index 0000000..831e16a --- /dev/null +++ b/test/chains/stellar/fee.test.ts @@ -0,0 +1,98 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { estimateFee, clearFeeCache } from '../../../src/chains/stellar/fee'; + +function mockFeeStats(p10: string, p50: string, p90: string) { + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ + ok: true, + json: async () => ({ fee_charged: { p10, p50, p90 } }), + })), + ); +} + +afterEach(() => { + vi.unstubAllGlobals(); + clearFeeCache(); +}); + +describe('estimateFee', () => { + test('returns p50 for normal urgency', async () => { + mockFeeStats('150', '300', '600'); + expect(await estimateFee('payment', 'normal')).toBe('300'); + }); + + test('returns p10 for low urgency', async () => { + mockFeeStats('150', '300', '600'); + expect(await estimateFee('payment', 'low')).toBe('150'); + }); + + test('returns p90 for high urgency', async () => { + mockFeeStats('150', '300', '600'); + expect(await estimateFee('payment', 'high')).toBe('600'); + }); + + test('applies 10x multiplier for soroban ops', async () => { + mockFeeStats('150', '300', '600'); + expect(await estimateFee('soroban', 'normal')).toBe('3000'); + }); + + test('floors at MIN_FEE (100) when stats return zero', async () => { + mockFeeStats('0', '0', '0'); + expect(await estimateFee('payment', 'normal')).toBe('100'); + }); + + test('floors at MIN_FEE when stats return value below minimum', async () => { + mockFeeStats('10', '50', '80'); + expect(await estimateFee('payment', 'low')).toBe('100'); + }); + + test('caches the response — only one fetch for two calls', async () => { + const fetchMock = vi.fn(async () => ({ + ok: true, + json: async () => ({ fee_charged: { p10: '100', p50: '200', p90: '400' } }), + })); + vi.stubGlobal('fetch', fetchMock); + + await estimateFee('payment', 'normal'); + await estimateFee('create_account', 'high'); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + test('re-fetches after cache is cleared', async () => { + const fetchMock = vi.fn(async () => ({ + ok: true, + json: async () => ({ fee_charged: { p10: '100', p50: '200', p90: '400' } }), + })); + vi.stubGlobal('fetch', fetchMock); + + await estimateFee('payment', 'normal'); + clearFeeCache(); + await estimateFee('payment', 'normal'); + + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + test('throws when Horizon returns a non-ok response', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ ok: false, status: 503 })), + ); + await expect(estimateFee('payment', 'normal')).rejects.toThrow('503'); + }); + + test('create_account uses same fee as payment', async () => { + mockFeeStats('150', '300', '600'); + const payment = await estimateFee('payment', 'normal'); + clearFeeCache(); + mockFeeStats('150', '300', '600'); + const createAccount = await estimateFee('create_account', 'normal'); + expect(payment).toBe(createAccount); + }); + + test('defaults to normal urgency when omitted', async () => { + mockFeeStats('150', '300', '600'); + expect(await estimateFee('payment')).toBe('300'); + }); +});