|
| 1 | +/** |
| 2 | + * Ledger Close Time Estimator |
| 3 | + * |
| 4 | + * Computes a rolling-average ledger close interval from recent Horizon ledger |
| 5 | + * history and projects future close times / ledger sequences. Used by |
| 6 | + * DeadlineEngine and StellarSplitTxBuilder for accurate timebounds computation. |
| 7 | + * |
| 8 | + * Stellar targets a ~5-second close interval but actual intervals vary with |
| 9 | + * network load. Calibrating from real history gives much better estimates. |
| 10 | + */ |
| 11 | + |
| 12 | +import { Horizon } from "@stellar/stellar-sdk"; |
| 13 | + |
| 14 | +// --------------------------------------------------------------------------- |
| 15 | +// Types |
| 16 | +// --------------------------------------------------------------------------- |
| 17 | + |
| 18 | +/** A condensed ledger record used internally for calibration. */ |
| 19 | +export interface LedgerRecord { |
| 20 | + /** Ledger sequence number. */ |
| 21 | + sequence: number; |
| 22 | + /** ISO-8601 timestamp string of when the ledger closed. */ |
| 23 | + closed_at: string; |
| 24 | +} |
| 25 | + |
| 26 | +/** Options for creating a {@link LedgerCloseEstimator}. */ |
| 27 | +export interface LedgerCloseEstimatorOptions { |
| 28 | + /** |
| 29 | + * Base URL for the Horizon server. |
| 30 | + * @example "https://horizon-testnet.stellar.org" |
| 31 | + */ |
| 32 | + horizonUrl: string; |
| 33 | + /** |
| 34 | + * How often to re-calibrate automatically, in milliseconds. |
| 35 | + * @default 300_000 (5 minutes) |
| 36 | + */ |
| 37 | + calibrationIntervalMs?: number; |
| 38 | + /** |
| 39 | + * Default number of recent ledgers to fetch during calibration. |
| 40 | + * @default 20 |
| 41 | + */ |
| 42 | + defaultSampleSize?: number; |
| 43 | +} |
| 44 | + |
| 45 | +/** Internal calibration state after a successful {@link LedgerCloseEstimator.calibrate} call. */ |
| 46 | +export interface CalibrationState { |
| 47 | + /** Rolling-average close interval in milliseconds. */ |
| 48 | + avgIntervalMs: number; |
| 49 | + /** The most-recent ledger sequence number in the sample. */ |
| 50 | + latestSequence: number; |
| 51 | + /** The epoch-ms timestamp at which that ledger closed. */ |
| 52 | + latestClosedAtMs: number; |
| 53 | + /** When this calibration was computed (epoch ms). */ |
| 54 | + calibratedAt: number; |
| 55 | +} |
| 56 | + |
| 57 | +// --------------------------------------------------------------------------- |
| 58 | +// Estimator implementation |
| 59 | +// --------------------------------------------------------------------------- |
| 60 | + |
| 61 | +/** |
| 62 | + * Estimates future ledger close times by computing a rolling-average close |
| 63 | + * interval from recent Horizon ledger history. |
| 64 | + * |
| 65 | + * @example |
| 66 | + * ```typescript |
| 67 | + * const estimator = new LedgerCloseEstimator({ |
| 68 | + * horizonUrl: "https://horizon-testnet.stellar.org", |
| 69 | + * }); |
| 70 | + * |
| 71 | + * // Calibrate once before using |
| 72 | + * await estimator.calibrate(); |
| 73 | + * |
| 74 | + * // Estimate when ledger 100 ledgers ahead will close |
| 75 | + * const currentLedger = 100_000; |
| 76 | + * const targetLedger = currentLedger + 100; |
| 77 | + * const closeTime = estimator.estimateCloseTime(targetLedger); |
| 78 | + * |
| 79 | + * // Or: which ledger will be current in 10 minutes? |
| 80 | + * const futureTime = new Date(Date.now() + 10 * 60_000); |
| 81 | + * const futureLedger = estimator.estimateLedgerAtTime(futureTime); |
| 82 | + * ``` |
| 83 | + */ |
| 84 | +export class LedgerCloseEstimator { |
| 85 | + private readonly horizonUrl: string; |
| 86 | + private readonly calibrationIntervalMs: number; |
| 87 | + private readonly defaultSampleSize: number; |
| 88 | + |
| 89 | + private _state: CalibrationState | null = null; |
| 90 | + private _autoTimer: ReturnType<typeof setInterval> | null = null; |
| 91 | + |
| 92 | + /** Fallback interval assumed when no calibration data is available (ms). */ |
| 93 | + static readonly FALLBACK_INTERVAL_MS = 5_000; |
| 94 | + |
| 95 | + constructor(options: LedgerCloseEstimatorOptions) { |
| 96 | + this.horizonUrl = options.horizonUrl.replace(/\/$/, ""); |
| 97 | + this.calibrationIntervalMs = options.calibrationIntervalMs ?? 300_000; |
| 98 | + this.defaultSampleSize = options.defaultSampleSize ?? 20; |
| 99 | + } |
| 100 | + |
| 101 | + // --------------------------------------------------------------------------- |
| 102 | + // Public API |
| 103 | + // --------------------------------------------------------------------------- |
| 104 | + |
| 105 | + /** |
| 106 | + * Fetch the last `sampleSize` ledgers from Horizon and compute the |
| 107 | + * rolling-average close interval in milliseconds. |
| 108 | + * |
| 109 | + * Call this at least once before using the projection methods. Subsequent |
| 110 | + * calls refresh the calibration data. |
| 111 | + * |
| 112 | + * @param sampleSize - How many recent ledgers to include in the average. |
| 113 | + * Defaults to the value set in the constructor options (20). |
| 114 | + */ |
| 115 | + async calibrate(sampleSize?: number): Promise<void> { |
| 116 | + const n = sampleSize ?? this.defaultSampleSize; |
| 117 | + const records = await this._fetchLedgers(n); |
| 118 | + |
| 119 | + if (records.length < 2) { |
| 120 | + // Not enough data — keep any existing state or use the fallback. |
| 121 | + return; |
| 122 | + } |
| 123 | + |
| 124 | + // Sort ascending by sequence so we can compute deltas in order. |
| 125 | + const sorted = [...records].sort((a, b) => a.sequence - b.sequence); |
| 126 | + |
| 127 | + // Compute consecutive close-time deltas. |
| 128 | + const deltas: number[] = []; |
| 129 | + for (let i = 1; i < sorted.length; i++) { |
| 130 | + const prev = new Date(sorted[i - 1]!.closed_at).getTime(); |
| 131 | + const curr = new Date(sorted[i]!.closed_at).getTime(); |
| 132 | + const delta = curr - prev; |
| 133 | + if (delta > 0) deltas.push(delta); |
| 134 | + } |
| 135 | + |
| 136 | + if (deltas.length === 0) return; |
| 137 | + |
| 138 | + const avgIntervalMs = deltas.reduce((s, d) => s + d, 0) / deltas.length; |
| 139 | + |
| 140 | + const latest = sorted[sorted.length - 1]!; |
| 141 | + this._state = { |
| 142 | + avgIntervalMs, |
| 143 | + latestSequence: latest.sequence, |
| 144 | + latestClosedAtMs: new Date(latest.closed_at).getTime(), |
| 145 | + calibratedAt: Date.now(), |
| 146 | + }; |
| 147 | + |
| 148 | + // Arm the auto-recalibration timer the first time calibrate() succeeds. |
| 149 | + if (this._autoTimer === null && this.calibrationIntervalMs > 0) { |
| 150 | + this._autoTimer = setInterval( |
| 151 | + () => void this.calibrate(n), |
| 152 | + this.calibrationIntervalMs, |
| 153 | + ); |
| 154 | + // Keep Node.js from blocking the exit due to this timer. |
| 155 | + if ( |
| 156 | + typeof this._autoTimer === "object" && |
| 157 | + this._autoTimer !== null && |
| 158 | + typeof (this._autoTimer as NodeJS.Timeout).unref === "function" |
| 159 | + ) { |
| 160 | + (this._autoTimer as NodeJS.Timeout).unref(); |
| 161 | + } |
| 162 | + } |
| 163 | + } |
| 164 | + |
| 165 | + /** |
| 166 | + * Project the wall-clock time at which `targetLedger` will close. |
| 167 | + * |
| 168 | + * Uses the rolling-average interval from the last calibration. Falls back |
| 169 | + * to a 5-second interval when not yet calibrated. |
| 170 | + * |
| 171 | + * @param targetLedger - The future ledger sequence number to project. |
| 172 | + * @returns A {@link Date} representing the estimated close time. |
| 173 | + */ |
| 174 | + estimateCloseTime(targetLedger: number): Date { |
| 175 | + const state = this._state; |
| 176 | + if (!state) { |
| 177 | + // No calibration — project from now using the fallback interval. |
| 178 | + const ledgerAge = targetLedger - this._guessCurrentLedger(); |
| 179 | + const msFromNow = ledgerAge * LedgerCloseEstimator.FALLBACK_INTERVAL_MS; |
| 180 | + return new Date(Date.now() + Math.max(0, msFromNow)); |
| 181 | + } |
| 182 | + |
| 183 | + const ledgerDelta = targetLedger - state.latestSequence; |
| 184 | + const msFromLatest = ledgerDelta * state.avgIntervalMs; |
| 185 | + return new Date(state.latestClosedAtMs + msFromLatest); |
| 186 | + } |
| 187 | + |
| 188 | + /** |
| 189 | + * Project which ledger sequence will be current at `targetTime`. |
| 190 | + * |
| 191 | + * Uses the rolling-average interval from the last calibration. Falls back |
| 192 | + * to a 5-second interval when not yet calibrated. |
| 193 | + * |
| 194 | + * @param targetTime - The future point in time to project. |
| 195 | + * @returns The estimated ledger sequence number at that time. |
| 196 | + */ |
| 197 | + estimateLedgerAtTime(targetTime: Date): number { |
| 198 | + const state = this._state; |
| 199 | + const targetMs = targetTime.getTime(); |
| 200 | + |
| 201 | + if (!state) { |
| 202 | + const msFromNow = targetMs - Date.now(); |
| 203 | + const ledgersFromNow = msFromNow / LedgerCloseEstimator.FALLBACK_INTERVAL_MS; |
| 204 | + return Math.round(this._guessCurrentLedger() + ledgersFromNow); |
| 205 | + } |
| 206 | + |
| 207 | + const msFromLatest = targetMs - state.latestClosedAtMs; |
| 208 | + const ledgersFromLatest = msFromLatest / state.avgIntervalMs; |
| 209 | + return Math.round(state.latestSequence + ledgersFromLatest); |
| 210 | + } |
| 211 | + |
| 212 | + /** |
| 213 | + * Returns the current {@link CalibrationState} or `null` if not yet calibrated. |
| 214 | + */ |
| 215 | + get state(): CalibrationState | null { |
| 216 | + return this._state; |
| 217 | + } |
| 218 | + |
| 219 | + /** |
| 220 | + * Returns the rolling-average close interval in milliseconds. |
| 221 | + * Falls back to {@link LedgerCloseEstimator.FALLBACK_INTERVAL_MS} when not calibrated. |
| 222 | + */ |
| 223 | + get avgIntervalMs(): number { |
| 224 | + return this._state?.avgIntervalMs ?? LedgerCloseEstimator.FALLBACK_INTERVAL_MS; |
| 225 | + } |
| 226 | + |
| 227 | + /** |
| 228 | + * Stop the automatic re-calibration timer. |
| 229 | + */ |
| 230 | + destroy(): void { |
| 231 | + if (this._autoTimer !== null) { |
| 232 | + clearInterval(this._autoTimer); |
| 233 | + this._autoTimer = null; |
| 234 | + } |
| 235 | + } |
| 236 | + |
| 237 | + // --------------------------------------------------------------------------- |
| 238 | + // Private helpers |
| 239 | + // --------------------------------------------------------------------------- |
| 240 | + |
| 241 | + /** Fetch the last `n` ledgers from Horizon, sorted descending by sequence. */ |
| 242 | + private async _fetchLedgers(n: number): Promise<LedgerRecord[]> { |
| 243 | + const server = new Horizon.Server(this.horizonUrl, { |
| 244 | + allowHttp: this.horizonUrl.startsWith("http://"), |
| 245 | + }); |
| 246 | + |
| 247 | + const response = await server |
| 248 | + .ledgers() |
| 249 | + .order("desc") |
| 250 | + .limit(Math.max(2, Math.min(n, 200))) |
| 251 | + .call(); |
| 252 | + |
| 253 | + return (response.records as Array<{ sequence: number; closed_at: string }>).map( |
| 254 | + (r) => ({ |
| 255 | + sequence: r.sequence, |
| 256 | + closed_at: r.closed_at, |
| 257 | + }), |
| 258 | + ); |
| 259 | + } |
| 260 | + |
| 261 | + /** |
| 262 | + * Very rough guess at the current ledger sequence based on a well-known |
| 263 | + * Stellar mainnet genesis ledger (32570) and the fallback interval. |
| 264 | + * Only used as a fallback when no calibration data is available. |
| 265 | + */ |
| 266 | + private _guessCurrentLedger(): number { |
| 267 | + // Stellar mainnet genesis was Jan 2015. |
| 268 | + // Using a rough constant is fine here — this path is only taken when not calibrated. |
| 269 | + const GENESIS_LEDGER = 32_570; |
| 270 | + const GENESIS_TIMESTAMP_MS = new Date("2015-10-01T00:00:00Z").getTime(); |
| 271 | + const elapsed = Date.now() - GENESIS_TIMESTAMP_MS; |
| 272 | + return ( |
| 273 | + GENESIS_LEDGER + Math.floor(elapsed / LedgerCloseEstimator.FALLBACK_INTERVAL_MS) |
| 274 | + ); |
| 275 | + } |
| 276 | +} |
0 commit comments