Skip to content

Commit 797a8fb

Browse files
authored
Merge pull request #596 from Ebuka042-pixel/feature/547-ledger-close-estimator
feat(#547): add LedgerCloseEstimator with rolling-average calibration
2 parents d77cc1d + 2681377 commit 797a8fb

5 files changed

Lines changed: 648 additions & 0 deletions

File tree

src/deadlineEngine.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type { LedgerCloseEstimator } from "./ledgerCloseEstimator.js";
2+
13
type TimeoutLike = ReturnType<typeof setInterval>;
24

35
export interface CountdownOptions {
@@ -393,4 +395,23 @@ export class DeadlineEngine {
393395
this.interval = null;
394396
}
395397
}
398+
399+
/**
400+
* Derive a Unix-seconds deadline from a future ledger sequence number using
401+
* a {@link LedgerCloseEstimator}.
402+
*
403+
* This lets callers express payment expiry in ledger terms and get a
404+
* wall-clock deadline that can be passed to `getCountdown()` or stored on
405+
* the invoice.
406+
*
407+
* @param targetLedger - The ledger sequence at which funds should expire.
408+
* @param estimator - A calibrated {@link LedgerCloseEstimator}.
409+
* @returns Unix timestamp in seconds for the projected close time.
410+
*/
411+
estimateDeadlineFromLedger(
412+
targetLedger: number,
413+
estimator: LedgerCloseEstimator,
414+
): number {
415+
return Math.floor(estimator.estimateCloseTime(targetLedger).getTime() / 1000);
416+
}
396417
}

src/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,13 @@ export { watchExpiry } from "./watcher.js";
334334

335335
export { DeadlineEngine } from "./deadlineEngine.js";
336336

337+
export { LedgerCloseEstimator } from "./ledgerCloseEstimator.js";
338+
export type {
339+
LedgerCloseEstimatorOptions,
340+
LedgerRecord,
341+
CalibrationState,
342+
} from "./ledgerCloseEstimator.js";
343+
337344
export { StellarSplitTxBuilder } from "./txBuilder.js";
338345

339346
export { SequenceCache, isSequenceTooOld } from "./sequenceCache.js";

src/ledgerCloseEstimator.ts

Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
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+
}

src/txBuilder.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type { StellarSplitClientConfig } from "./client.js";
1414
import { signTransaction } from "./wallet.js";
1515
import { SimulationFailedError, TransactionFailedError, TransactionNotConfirmedError } from "./errors.js";
1616
import { checkInvoiceExpiry } from "./preflightChecker.js";
17+
import type { LedgerCloseEstimator } from "./ledgerCloseEstimator.js";
1718

1819
/** Builder for composing multi-operation StellarSplit transactions. */
1920
export class StellarSplitTxBuilder {
@@ -23,6 +24,7 @@ export class StellarSplitTxBuilder {
2324
private readonly sourceAddress: string;
2425
private readonly operations: xdr.Operation[] = [];
2526
private _surgeConfig?: FeeSurgeConfig;
27+
private _ledgerEstimator?: LedgerCloseEstimator;
2628

2729
constructor(config: StellarSplitClientConfig, sourceAddress: string) {
2830
this.config = config;
@@ -42,6 +44,34 @@ export class StellarSplitTxBuilder {
4244
return this;
4345
}
4446

47+
/**
48+
* Attach a {@link LedgerCloseEstimator} to derive precise timebounds from
49+
* ledger sequence numbers rather than fixed wall-clock offsets.
50+
*
51+
* When set, you can pass `targetLedger` to {@link build} / {@link submit}
52+
* and the builder will compute `timebounds.maxTime` via the estimator.
53+
*
54+
* @param estimator - A calibrated {@link LedgerCloseEstimator}.
55+
*/
56+
setLedgerEstimator(estimator: LedgerCloseEstimator): this {
57+
this._ledgerEstimator = estimator;
58+
return this;
59+
}
60+
61+
/**
62+
* Compute a wall-clock `expiresAt` (Unix seconds) from a target ledger
63+
* sequence, using the attached {@link LedgerCloseEstimator}.
64+
* Returns `undefined` when no estimator has been set.
65+
*
66+
* @param targetLedger - The ledger sequence after which the tx should expire.
67+
*/
68+
expiresAtFromLedger(targetLedger: number): number | undefined {
69+
if (!this._ledgerEstimator) return undefined;
70+
return Math.floor(
71+
this._ledgerEstimator.estimateCloseTime(targetLedger).getTime() / 1000,
72+
);
73+
}
74+
4575
addPay(invoiceId: string, amount: bigint | number | string): this {
4676
const op = this.contract.call(
4777
"pay",

0 commit comments

Comments
 (0)