Skip to content
Merged
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
65 changes: 65 additions & 0 deletions docs/fee-estimation.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,10 @@ export type {
SubmissionOutcome,
RetryPolicy,
RetryPolicyExhaustedResult,
FeeEstimate,
} from './types';


export {
PocketPayError,
TransactionDirection,
Expand Down Expand Up @@ -294,6 +296,7 @@ export {
submitTransactionIdempotently,
pollTransactionStatus,
withRetryPolicy,
fetchFeeEstimate,
} from './network';

// ─── Errors ─────────────────────────────────────────────────────────────────
Expand Down
47 changes: 47 additions & 0 deletions src/network/fee.ts
Original file line number Diff line number Diff line change
@@ -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<SDKConfig>): Promise<FeeEstimate> {
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,
};
}
}
2 changes: 1 addition & 1 deletion src/network/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,4 +299,4 @@ export async function executeSorobanOperation<T>(

export { submitTransactionIdempotently, pollTransactionStatus } from './idempotency';
export { withRetryPolicy } from './retry-policy';

export { fetchFeeEstimate } from './fee';
21 changes: 14 additions & 7 deletions src/transactions/offline-preparation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 */
Expand Down Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions src/types/transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
78 changes: 78 additions & 0 deletions tests/network-fee.test.ts
Original file line number Diff line number Diff line change
@@ -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));
});
});