diff --git a/docs/fee-estimation.md b/docs/fee-estimation.md new file mode 100644 index 0000000..aa01bec --- /dev/null +++ b/docs/fee-estimation.md @@ -0,0 +1,65 @@ +# Transaction Fee Estimation + +The PocketPay SDK provides robust transaction fee estimation capabilities to help consumers display safe fee bounds to users. + +Stellar transactions require a fee, and during times of high network activity, the base fee may not be enough to get a transaction included in the next ledger (surge pricing). The SDK exposes a `fetchFeeEstimate()` helper to resolve these estimates and handle fallback scenarios securely. + +## Fee Estimate Type + +A `FeeEstimate` breaks down network fee statistics into actionable tiers. All fee values are in **stroops** (1 XLM = 10,000,000 stroops). + +```typescript +export interface FeeEstimate { + /** The estimated fee for a high probability of fast inclusion (e.g., p95) */ + high: string; + /** The estimated fee for standard/average inclusion (e.g., p50) */ + standard: string; + /** The estimated fee for low priority inclusion (e.g., p10) */ + low: string; + /** The absolute minimum base fee required by the network (usually 100 stroops) */ + baseFee: string; + + /** True if the network is experiencing high capacity usage (> 80%) */ + surgePricing: boolean; + /** True if the fee stats could not be fetched and the SDK is falling back */ + isFallback: boolean; +} +``` + +## Obtaining Fee Estimates + +You can fetch fee estimates directly using `fetchFeeEstimate`: + +```typescript +import { fetchFeeEstimate } from 'pocketpay-sdk'; + +const estimate = await fetchFeeEstimate(); +console.log(`Standard fee recommendation: ${estimate.standard} stroops`); + +if (estimate.surgePricing) { + console.warn("Network is busy, recommending the 'high' fee tier!"); +} +``` + +Alternatively, when using the offline transaction preparation workflow, `fetchNetworkState()` automatically resolves the fee estimate for you: + +```typescript +import { fetchNetworkState } from 'pocketpay-sdk'; + +const state = await fetchNetworkState('G...'); +console.log(state.feeEstimate); +``` + +## Fallback Behaviour + +If the Horizon `/fee_stats` endpoint is unreachable or fails for any reason, the SDK **does not throw an error**. Instead, it falls back to safe default minimums derived from the hardcoded `StellarSDK.BASE_FEE` (100 stroops). + +In fallback mode: +- `isFallback` is set to `true`. +- `surgePricing` is set to `false`. +- The tiers are calculated as multiples of the base fee (e.g., `high` = 500, `standard` = 200, `low` = 100). + +## Uncertainty Handling and Best Practices + +1. **Never Guarantee Exact Fees:** Stellar fee estimation provides an *upper bound* (`max_fee`), not an exact cost. The network will only charge the minimum fee required for inclusion (`fee_charged`), up to the transaction's `max_fee`. You should clarify to users that the fee is an *estimate* or a *maximum*. +2. **Surge Pricing:** Use the `surgePricing` boolean to decide whether to prompt the user to use a higher fee or warn them about potential delays. diff --git a/src/index.ts b/src/index.ts index bc36ad8..cb18bfb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -82,8 +82,10 @@ export type { SubmissionOutcome, RetryPolicy, RetryPolicyExhaustedResult, + FeeEstimate, } from './types'; + export { PocketPayError, TransactionDirection, @@ -294,6 +296,7 @@ export { submitTransactionIdempotently, pollTransactionStatus, withRetryPolicy, + fetchFeeEstimate, } from './network'; // ─── Errors ───────────────────────────────────────────────────────────────── diff --git a/src/network/fee.ts b/src/network/fee.ts new file mode 100644 index 0000000..1f60c95 --- /dev/null +++ b/src/network/fee.ts @@ -0,0 +1,47 @@ +import * as StellarSDK from '@stellar/stellar-sdk'; +import { getHorizonServer, resolveConfig } from '../config'; +import { FeeEstimate, SDKConfig } from '../types'; +import { executeHorizonOperation } from './index'; + +/** + * Fetches the current fee estimation from the network. + * If the network request fails, falls back to safe default minimums. + * + * @param config Optional SDK config to determine network + * @returns A FeeEstimate object containing tiered fee suggestions + */ +export async function fetchFeeEstimate(config?: Partial): Promise { + const cfg = resolveConfig(config); + const server = getHorizonServer(config); + + try { + const feeStats = await executeHorizonOperation( + 'Fetch fee stats', + cfg.timeout, + () => server.feeStats() + ); + + // Parse capacity usage to determine surge pricing (e.g. > 0.8) + const capacityUsage = parseFloat(feeStats.ledger_capacity_usage); + const surgePricing = capacityUsage > 0.8; + + return { + high: String(feeStats.max_fee.p95), + standard: String(feeStats.max_fee.p50), + low: String(feeStats.max_fee.p10), + baseFee: String(feeStats.last_ledger_base_fee), + surgePricing, + isFallback: false, + }; + } catch (error) { + // Fallback behaviour + return { + high: String(StellarSDK.BASE_FEE * 5), + standard: String(StellarSDK.BASE_FEE * 2), + low: String(StellarSDK.BASE_FEE), + baseFee: String(StellarSDK.BASE_FEE), + surgePricing: false, + isFallback: true, + }; + } +} diff --git a/src/network/index.ts b/src/network/index.ts index b824a58..676e060 100644 --- a/src/network/index.ts +++ b/src/network/index.ts @@ -299,4 +299,4 @@ export async function executeSorobanOperation( export { submitTransactionIdempotently, pollTransactionStatus } from './idempotency'; export { withRetryPolicy } from './retry-policy'; - +export { fetchFeeEstimate } from './fee'; diff --git a/src/transactions/offline-preparation.ts b/src/transactions/offline-preparation.ts index 5417ac9..38efdef 100644 --- a/src/transactions/offline-preparation.ts +++ b/src/transactions/offline-preparation.ts @@ -45,9 +45,10 @@ import { PocketPayError, SDKConfig, PocketPayResult, + FeeEstimate, } from '../types'; import { validatePublicKey, validateSecretKey, validateAmount, validateMemoInput, buildMemo, wrapError, toResult } from '../utils'; -import { withTimeout } from '../network'; +import { withTimeout, fetchFeeEstimate } from '../network'; import { submitWithGuard } from './guarded-submit'; import { ErrorCategory, ErrorCode, ERROR_CODES } from '../errors'; import { validateSequenceValue, isSequenceStale } from '../account/sequence'; @@ -106,6 +107,8 @@ export interface NetworkState { fetchedAt?: number; /** Current base fee from network (optional - can use default) */ currentFee?: string; + /** Detailed fee estimation containing high/standard/low tiers */ + feeEstimate?: FeeEstimate; /** Account balance information (optional - for validation) */ balance?: { /** Native XLM balance */ @@ -497,16 +500,20 @@ export async function fetchNetworkState( const server = getHorizonServer(config); try { - const account = await withTimeout( - 'Horizon account lookup for transaction preparation', - cfg.timeout, - server.loadAccount(publicKey), - ) as any; + const [account, feeEstimate] = await Promise.all([ + withTimeout( + 'Horizon account lookup for transaction preparation', + cfg.timeout, + server.loadAccount(publicKey), + ) as any, + fetchFeeEstimate(config) + ]); return { sequence: account.sequence, fetchedAt: Date.now(), - currentFee: String(StellarSDK.BASE_FEE), // Could fetch actual fee from network + currentFee: feeEstimate.baseFee, + feeEstimate, balance: { native: account.balances?.find((b: any) => b.asset_type === 'native')?.balance || '0', minimum: '2.5', // Minimum reserve on Stellar diff --git a/src/types/transaction.ts b/src/types/transaction.ts index 1ed8cc5..8bfffc2 100644 --- a/src/types/transaction.ts +++ b/src/types/transaction.ts @@ -133,3 +133,28 @@ export interface TransactionMapperOptions { /** Whether to format amounts with proper decimals */ formatAmounts?: boolean; } + +/** + * Represents an estimated fee for a transaction, derived from recent network statistics. + * All fee values are in stroops (1 XLM = 10,000,000 stroops). + */ +export interface FeeEstimate { + /** The estimated fee for a high probability of fast inclusion (e.g., p95) */ + high: string; + /** The estimated fee for standard/average inclusion (e.g., p50) */ + standard: string; + /** The estimated fee for low priority inclusion (e.g., p10) */ + low: string; + /** The absolute minimum base fee required by the network (usually 100 stroops) */ + baseFee: string; + /** + * True if the network is experiencing high capacity usage (surge pricing). + * Consumers should warn users or recommend the 'high' fee tier. + */ + surgePricing: boolean; + /** + * True if the fee stats could not be fetched and the SDK is falling back + * to default minimums. Uncertainty is high. + */ + isFallback: boolean; +} diff --git a/tests/network-fee.test.ts b/tests/network-fee.test.ts new file mode 100644 index 0000000..5777dd2 --- /dev/null +++ b/tests/network-fee.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { fetchFeeEstimate } from '../src/network/fee'; +import { getHorizonServer } from '../src/config'; +import * as StellarSDK from '@stellar/stellar-sdk'; + +vi.mock('../src/config', async () => { + const actual = await vi.importActual('../src/config'); + return { + ...actual, + getHorizonServer: vi.fn(), + }; +}); + +describe('Network Fee Estimation', () => { + let mockFeeStats: vi.Mock; + + beforeEach(() => { + vi.clearAllMocks(); + + mockFeeStats = vi.fn(); + (getHorizonServer as vi.Mock).mockReturnValue({ + feeStats: mockFeeStats, + }); + }); + + it('should parse fee stats correctly and determine surge pricing', async () => { + mockFeeStats.mockResolvedValue({ + last_ledger: '12345', + last_ledger_base_fee: '100', + ledger_capacity_usage: '0.85', + max_fee: { + p10: '100', + p50: '150', + p95: '500', + } + }); + + const result = await fetchFeeEstimate(); + + expect(result.isFallback).toBe(false); + expect(result.surgePricing).toBe(true); + expect(result.baseFee).toBe('100'); + expect(result.low).toBe('100'); + expect(result.standard).toBe('150'); + expect(result.high).toBe('500'); + }); + + it('should identify normal network conditions', async () => { + mockFeeStats.mockResolvedValue({ + last_ledger: '12345', + last_ledger_base_fee: '100', + ledger_capacity_usage: '0.40', + max_fee: { + p10: '100', + p50: '100', + p95: '200', + } + }); + + const result = await fetchFeeEstimate(); + + expect(result.isFallback).toBe(false); + expect(result.surgePricing).toBe(false); + }); + + it('should fallback to defaults when Horizon request fails', async () => { + mockFeeStats.mockRejectedValue(new Error('Network offline')); + + const result = await fetchFeeEstimate(); + + expect(result.isFallback).toBe(true); + expect(result.surgePricing).toBe(false); + expect(result.baseFee).toBe(String(StellarSDK.BASE_FEE)); + expect(result.low).toBe(String(StellarSDK.BASE_FEE)); + expect(result.standard).toBe(String(StellarSDK.BASE_FEE * 2)); + expect(result.high).toBe(String(StellarSDK.BASE_FEE * 5)); + }); +});