|
| 1 | +/** |
| 2 | + * Fee surge detector — monitors real-time ledger fee statistics via Horizon |
| 3 | + * and automatically recommends an adjusted fee multiplier during network |
| 4 | + * congestion so transactions don't fail with hard-coded fee values. |
| 5 | + * |
| 6 | + * Extends {@link src/feeEstimator.ts} and {@link src/fee.ts} with surge-aware |
| 7 | + * behaviour. |
| 8 | + */ |
| 9 | + |
| 10 | +import { rpc as SorobanRpc, Horizon } from "@stellar/stellar-sdk"; |
| 11 | + |
| 12 | +// --------------------------------------------------------------------------- |
| 13 | +// Types |
| 14 | +// --------------------------------------------------------------------------- |
| 15 | + |
| 16 | +/** |
| 17 | + * Configuration for the fee surge detector. |
| 18 | + */ |
| 19 | +export interface FeeSurgeConfig { |
| 20 | + /** |
| 21 | + * Fee percentile to track as the "current" network fee. |
| 22 | + * |
| 23 | + * - `"p10"` — conservative (lowest fee that 90 % of ledgers accept). |
| 24 | + * - `"p50"` — median fee. |
| 25 | + * - `"p95"` — aggressive (only 5 % of ledgers require a higher fee). |
| 26 | + * |
| 27 | + * Defaults to `"p50"`. |
| 28 | + */ |
| 29 | + percentile?: "p10" | "p50" | "p95"; |
| 30 | + |
| 31 | + /** |
| 32 | + * Congestion threshold multiplier. When the observed fee exceeds |
| 33 | + * `baseFee * surgeMultiplier`, the network is considered congested. |
| 34 | + * Defaults to `2`. |
| 35 | + */ |
| 36 | + surgeMultiplier?: number; |
| 37 | + |
| 38 | + /** |
| 39 | + * Recommended fee multiplier applied during surge. Defaults to `1.5` |
| 40 | + * (i.e. pay 50 % more than the observed percentile fee during surge). |
| 41 | + */ |
| 42 | + surgeFeeMultiplier?: number; |
| 43 | + |
| 44 | + /** |
| 45 | + * How long a surge recommendation is cached (ms). Defaults to 30_000. |
| 46 | + */ |
| 47 | + cacheTtlMs?: number; |
| 48 | + |
| 49 | + /** |
| 50 | + * Maximum fee in stroops the surge detector will ever recommend. Acts as |
| 51 | + * a safety ceiling. Defaults to 10_000_000 (10 XLM). |
| 52 | + */ |
| 53 | + maxFeeStroops?: number; |
| 54 | +} |
| 55 | + |
| 56 | +/** Congestion level derived from fee statistics. */ |
| 57 | +export type CongestionLevel = "low" | "medium" | "high"; |
| 58 | + |
| 59 | +/** |
| 60 | + * A fee recommendation produced by the surge detector. |
| 61 | + */ |
| 62 | +export interface FeeRecommendation { |
| 63 | + /** Recommended fee in stroops. */ |
| 64 | + fee: bigint; |
| 65 | + /** The base fee used as reference (in stroops). */ |
| 66 | + baseFee: bigint; |
| 67 | + /** The observed fee-percentile value (in stroops). */ |
| 68 | + observedFee: bigint; |
| 69 | + /** Current congestion level. */ |
| 70 | + congestion: CongestionLevel; |
| 71 | + /** Whether surge pricing is active. */ |
| 72 | + surgeActive: boolean; |
| 73 | + /** Multiplier applied to the base fee. */ |
| 74 | + multiplier: number; |
| 75 | + /** Unix timestamp (ms) when this recommendation was produced. */ |
| 76 | + timestamp: number; |
| 77 | +} |
| 78 | + |
| 79 | +// --------------------------------------------------------------------------- |
| 80 | +// Implementation |
| 81 | +// --------------------------------------------------------------------------- |
| 82 | + |
| 83 | +const DEFAULT_BASE_FEE = 100n; // 100 stroops |
| 84 | + |
| 85 | +let cachedRecommendation: FeeRecommendation | null = null; |
| 86 | +let cacheExpiry = 0; |
| 87 | + |
| 88 | +/** |
| 89 | + * Fetch the current fee statistics from Horizon and produce a surge-aware |
| 90 | + * fee recommendation. |
| 91 | + * |
| 92 | + * Uses {@link Horizon.Server.feeStats} which returns a {@link Horizon.FeeStatsResponse} |
| 93 | + * with `p10`, `p50`, `p95` fee percentiles. |
| 94 | + * |
| 95 | + * @param horizonUrl - Horizon server URL (e.g. "https://horizon.stellar.org"). |
| 96 | + * @param config - Optional surge detector configuration. |
| 97 | + * @returns A fee recommendation with congestion level and surge-adjusted fee. |
| 98 | + */ |
| 99 | +export async function detectFeeSurge( |
| 100 | + horizonUrl: string, |
| 101 | + config?: FeeSurgeConfig, |
| 102 | +): Promise<FeeRecommendation> { |
| 103 | + const now = Date.now(); |
| 104 | + const ttl = config?.cacheTtlMs ?? 30_000; |
| 105 | + |
| 106 | + // Return cached result if still fresh. |
| 107 | + if (cachedRecommendation && now < cacheExpiry) { |
| 108 | + return cachedRecommendation; |
| 109 | + } |
| 110 | + |
| 111 | + const percentile = config?.percentile ?? "p50"; |
| 112 | + const surgeMultiplier = config?.surgeMultiplier ?? 2; |
| 113 | + const surgeFeeMultiplier = config?.surgeFeeMultiplier ?? 1.5; |
| 114 | + const maxFee = BigInt(config?.maxFeeStroops ?? 10_000_000); |
| 115 | + const baseFee = DEFAULT_BASE_FEE; |
| 116 | + |
| 117 | + try { |
| 118 | + const server = new Horizon.Server(horizonUrl); |
| 119 | + const feeStats = await server.feeStats(); |
| 120 | + |
| 121 | + const observedFee = feePercentileToBigInt(feeStats, percentile); |
| 122 | + const surgeActive = observedFee > baseFee * BigInt(Math.ceil(surgeMultiplier)); |
| 123 | + |
| 124 | + let congestion: CongestionLevel; |
| 125 | + if (observedFee <= baseFee * 2n) { |
| 126 | + congestion = "low"; |
| 127 | + } else if (observedFee <= baseFee * 10n) { |
| 128 | + congestion = "medium"; |
| 129 | + } else { |
| 130 | + congestion = "high"; |
| 131 | + } |
| 132 | + |
| 133 | + let recommendedFee: bigint; |
| 134 | + let multiplier: number; |
| 135 | + |
| 136 | + if (surgeActive) { |
| 137 | + recommendedFee = BigInt( |
| 138 | + Math.ceil(Number(observedFee) * surgeFeeMultiplier), |
| 139 | + ); |
| 140 | + multiplier = surgeFeeMultiplier; |
| 141 | + } else { |
| 142 | + recommendedFee = observedFee; |
| 143 | + multiplier = 1.0; |
| 144 | + } |
| 145 | + |
| 146 | + // Apply safety ceiling |
| 147 | + if (recommendedFee > maxFee) { |
| 148 | + recommendedFee = maxFee; |
| 149 | + } |
| 150 | + |
| 151 | + const recommendation: FeeRecommendation = { |
| 152 | + fee: recommendedFee, |
| 153 | + baseFee, |
| 154 | + observedFee, |
| 155 | + congestion, |
| 156 | + surgeActive, |
| 157 | + multiplier, |
| 158 | + timestamp: now, |
| 159 | + }; |
| 160 | + |
| 161 | + // Cache the result |
| 162 | + cachedRecommendation = recommendation; |
| 163 | + cacheExpiry = now + ttl; |
| 164 | + |
| 165 | + return recommendation; |
| 166 | + } catch { |
| 167 | + // On failure, return a safe default (base fee with low congestion). |
| 168 | + return { |
| 169 | + fee: baseFee, |
| 170 | + baseFee, |
| 171 | + observedFee: baseFee, |
| 172 | + congestion: "low", |
| 173 | + surgeActive: false, |
| 174 | + multiplier: 1.0, |
| 175 | + timestamp: now, |
| 176 | + }; |
| 177 | + } |
| 178 | +} |
| 179 | + |
| 180 | +/** |
| 181 | + * Clear the internal fee recommendation cache so the next call to |
| 182 | + * {@link detectFeeSurge} fetches fresh data. |
| 183 | + */ |
| 184 | +export function clearFeeSurgeCache(): void { |
| 185 | + cachedRecommendation = null; |
| 186 | + cacheExpiry = 0; |
| 187 | +} |
| 188 | + |
| 189 | +/** |
| 190 | + * Extract a fee percentile from the Horizon fee stats response as a bigint |
| 191 | + * (in stroops). |
| 192 | + */ |
| 193 | +function feePercentileToBigInt( |
| 194 | + stats: any, |
| 195 | + percentile: "p10" | "p50" | "p95", |
| 196 | +): bigint { |
| 197 | + const raw = |
| 198 | + percentile === "p10" |
| 199 | + ? stats.feeCharged.p10 |
| 200 | + : percentile === "p50" |
| 201 | + ? stats.feeCharged.p50 |
| 202 | + : stats.feeCharged.p95; |
| 203 | + |
| 204 | + if (raw === undefined || raw === null) return DEFAULT_BASE_FEE; |
| 205 | + // Horizon returns fee values in stroops already. Ceil to the nearest |
| 206 | + // integer to avoid floating-point precision issues. |
| 207 | + return BigInt(Math.ceil(Number(raw))); |
| 208 | +} |
0 commit comments