diff --git a/src/deadlineEngine.ts b/src/deadlineEngine.ts index 325fe69..84311c1 100644 --- a/src/deadlineEngine.ts +++ b/src/deadlineEngine.ts @@ -1,3 +1,5 @@ +import type { LedgerCloseEstimator } from "./ledgerCloseEstimator.js"; + type TimeoutLike = ReturnType; export interface CountdownOptions { @@ -393,4 +395,23 @@ export class DeadlineEngine { this.interval = null; } } + + /** + * Derive a Unix-seconds deadline from a future ledger sequence number using + * a {@link LedgerCloseEstimator}. + * + * This lets callers express payment expiry in ledger terms and get a + * wall-clock deadline that can be passed to `getCountdown()` or stored on + * the invoice. + * + * @param targetLedger - The ledger sequence at which funds should expire. + * @param estimator - A calibrated {@link LedgerCloseEstimator}. + * @returns Unix timestamp in seconds for the projected close time. + */ + estimateDeadlineFromLedger( + targetLedger: number, + estimator: LedgerCloseEstimator, + ): number { + return Math.floor(estimator.estimateCloseTime(targetLedger).getTime() / 1000); + } } diff --git a/src/index.ts b/src/index.ts index 38d8fe1..1e31116 100644 --- a/src/index.ts +++ b/src/index.ts @@ -310,6 +310,13 @@ export { watchExpiry } from "./watcher.js"; export { DeadlineEngine } from "./deadlineEngine.js"; +export { LedgerCloseEstimator } from "./ledgerCloseEstimator.js"; +export type { + LedgerCloseEstimatorOptions, + LedgerRecord, + CalibrationState, +} from "./ledgerCloseEstimator.js"; + export { StellarSplitTxBuilder } from "./txBuilder.js"; export { SequenceCache, isSequenceTooOld } from "./sequenceCache.js"; diff --git a/src/ledgerCloseEstimator.ts b/src/ledgerCloseEstimator.ts new file mode 100644 index 0000000..6f79a75 --- /dev/null +++ b/src/ledgerCloseEstimator.ts @@ -0,0 +1,276 @@ +/** + * Ledger Close Time Estimator + * + * Computes a rolling-average ledger close interval from recent Horizon ledger + * history and projects future close times / ledger sequences. Used by + * DeadlineEngine and StellarSplitTxBuilder for accurate timebounds computation. + * + * Stellar targets a ~5-second close interval but actual intervals vary with + * network load. Calibrating from real history gives much better estimates. + */ + +import { Horizon } from "@stellar/stellar-sdk"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** A condensed ledger record used internally for calibration. */ +export interface LedgerRecord { + /** Ledger sequence number. */ + sequence: number; + /** ISO-8601 timestamp string of when the ledger closed. */ + closed_at: string; +} + +/** Options for creating a {@link LedgerCloseEstimator}. */ +export interface LedgerCloseEstimatorOptions { + /** + * Base URL for the Horizon server. + * @example "https://horizon-testnet.stellar.org" + */ + horizonUrl: string; + /** + * How often to re-calibrate automatically, in milliseconds. + * @default 300_000 (5 minutes) + */ + calibrationIntervalMs?: number; + /** + * Default number of recent ledgers to fetch during calibration. + * @default 20 + */ + defaultSampleSize?: number; +} + +/** Internal calibration state after a successful {@link LedgerCloseEstimator.calibrate} call. */ +export interface CalibrationState { + /** Rolling-average close interval in milliseconds. */ + avgIntervalMs: number; + /** The most-recent ledger sequence number in the sample. */ + latestSequence: number; + /** The epoch-ms timestamp at which that ledger closed. */ + latestClosedAtMs: number; + /** When this calibration was computed (epoch ms). */ + calibratedAt: number; +} + +// --------------------------------------------------------------------------- +// Estimator implementation +// --------------------------------------------------------------------------- + +/** + * Estimates future ledger close times by computing a rolling-average close + * interval from recent Horizon ledger history. + * + * @example + * ```typescript + * const estimator = new LedgerCloseEstimator({ + * horizonUrl: "https://horizon-testnet.stellar.org", + * }); + * + * // Calibrate once before using + * await estimator.calibrate(); + * + * // Estimate when ledger 100 ledgers ahead will close + * const currentLedger = 100_000; + * const targetLedger = currentLedger + 100; + * const closeTime = estimator.estimateCloseTime(targetLedger); + * + * // Or: which ledger will be current in 10 minutes? + * const futureTime = new Date(Date.now() + 10 * 60_000); + * const futureLedger = estimator.estimateLedgerAtTime(futureTime); + * ``` + */ +export class LedgerCloseEstimator { + private readonly horizonUrl: string; + private readonly calibrationIntervalMs: number; + private readonly defaultSampleSize: number; + + private _state: CalibrationState | null = null; + private _autoTimer: ReturnType | null = null; + + /** Fallback interval assumed when no calibration data is available (ms). */ + static readonly FALLBACK_INTERVAL_MS = 5_000; + + constructor(options: LedgerCloseEstimatorOptions) { + this.horizonUrl = options.horizonUrl.replace(/\/$/, ""); + this.calibrationIntervalMs = options.calibrationIntervalMs ?? 300_000; + this.defaultSampleSize = options.defaultSampleSize ?? 20; + } + + // --------------------------------------------------------------------------- + // Public API + // --------------------------------------------------------------------------- + + /** + * Fetch the last `sampleSize` ledgers from Horizon and compute the + * rolling-average close interval in milliseconds. + * + * Call this at least once before using the projection methods. Subsequent + * calls refresh the calibration data. + * + * @param sampleSize - How many recent ledgers to include in the average. + * Defaults to the value set in the constructor options (20). + */ + async calibrate(sampleSize?: number): Promise { + const n = sampleSize ?? this.defaultSampleSize; + const records = await this._fetchLedgers(n); + + if (records.length < 2) { + // Not enough data — keep any existing state or use the fallback. + return; + } + + // Sort ascending by sequence so we can compute deltas in order. + const sorted = [...records].sort((a, b) => a.sequence - b.sequence); + + // Compute consecutive close-time deltas. + const deltas: number[] = []; + for (let i = 1; i < sorted.length; i++) { + const prev = new Date(sorted[i - 1]!.closed_at).getTime(); + const curr = new Date(sorted[i]!.closed_at).getTime(); + const delta = curr - prev; + if (delta > 0) deltas.push(delta); + } + + if (deltas.length === 0) return; + + const avgIntervalMs = deltas.reduce((s, d) => s + d, 0) / deltas.length; + + const latest = sorted[sorted.length - 1]!; + this._state = { + avgIntervalMs, + latestSequence: latest.sequence, + latestClosedAtMs: new Date(latest.closed_at).getTime(), + calibratedAt: Date.now(), + }; + + // Arm the auto-recalibration timer the first time calibrate() succeeds. + if (this._autoTimer === null && this.calibrationIntervalMs > 0) { + this._autoTimer = setInterval( + () => void this.calibrate(n), + this.calibrationIntervalMs, + ); + // Keep Node.js from blocking the exit due to this timer. + if ( + typeof this._autoTimer === "object" && + this._autoTimer !== null && + typeof (this._autoTimer as NodeJS.Timeout).unref === "function" + ) { + (this._autoTimer as NodeJS.Timeout).unref(); + } + } + } + + /** + * Project the wall-clock time at which `targetLedger` will close. + * + * Uses the rolling-average interval from the last calibration. Falls back + * to a 5-second interval when not yet calibrated. + * + * @param targetLedger - The future ledger sequence number to project. + * @returns A {@link Date} representing the estimated close time. + */ + estimateCloseTime(targetLedger: number): Date { + const state = this._state; + if (!state) { + // No calibration — project from now using the fallback interval. + const ledgerAge = targetLedger - this._guessCurrentLedger(); + const msFromNow = ledgerAge * LedgerCloseEstimator.FALLBACK_INTERVAL_MS; + return new Date(Date.now() + Math.max(0, msFromNow)); + } + + const ledgerDelta = targetLedger - state.latestSequence; + const msFromLatest = ledgerDelta * state.avgIntervalMs; + return new Date(state.latestClosedAtMs + msFromLatest); + } + + /** + * Project which ledger sequence will be current at `targetTime`. + * + * Uses the rolling-average interval from the last calibration. Falls back + * to a 5-second interval when not yet calibrated. + * + * @param targetTime - The future point in time to project. + * @returns The estimated ledger sequence number at that time. + */ + estimateLedgerAtTime(targetTime: Date): number { + const state = this._state; + const targetMs = targetTime.getTime(); + + if (!state) { + const msFromNow = targetMs - Date.now(); + const ledgersFromNow = msFromNow / LedgerCloseEstimator.FALLBACK_INTERVAL_MS; + return Math.round(this._guessCurrentLedger() + ledgersFromNow); + } + + const msFromLatest = targetMs - state.latestClosedAtMs; + const ledgersFromLatest = msFromLatest / state.avgIntervalMs; + return Math.round(state.latestSequence + ledgersFromLatest); + } + + /** + * Returns the current {@link CalibrationState} or `null` if not yet calibrated. + */ + get state(): CalibrationState | null { + return this._state; + } + + /** + * Returns the rolling-average close interval in milliseconds. + * Falls back to {@link LedgerCloseEstimator.FALLBACK_INTERVAL_MS} when not calibrated. + */ + get avgIntervalMs(): number { + return this._state?.avgIntervalMs ?? LedgerCloseEstimator.FALLBACK_INTERVAL_MS; + } + + /** + * Stop the automatic re-calibration timer. + */ + destroy(): void { + if (this._autoTimer !== null) { + clearInterval(this._autoTimer); + this._autoTimer = null; + } + } + + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + /** Fetch the last `n` ledgers from Horizon, sorted descending by sequence. */ + private async _fetchLedgers(n: number): Promise { + const server = new Horizon.Server(this.horizonUrl, { + allowHttp: this.horizonUrl.startsWith("http://"), + }); + + const response = await server + .ledgers() + .order("desc") + .limit(Math.max(2, Math.min(n, 200))) + .call(); + + return (response.records as Array<{ sequence: number; closed_at: string }>).map( + (r) => ({ + sequence: r.sequence, + closed_at: r.closed_at, + }), + ); + } + + /** + * Very rough guess at the current ledger sequence based on a well-known + * Stellar mainnet genesis ledger (32570) and the fallback interval. + * Only used as a fallback when no calibration data is available. + */ + private _guessCurrentLedger(): number { + // Stellar mainnet genesis was Jan 2015. + // Using a rough constant is fine here — this path is only taken when not calibrated. + const GENESIS_LEDGER = 32_570; + const GENESIS_TIMESTAMP_MS = new Date("2015-10-01T00:00:00Z").getTime(); + const elapsed = Date.now() - GENESIS_TIMESTAMP_MS; + return ( + GENESIS_LEDGER + Math.floor(elapsed / LedgerCloseEstimator.FALLBACK_INTERVAL_MS) + ); + } +} diff --git a/src/txBuilder.ts b/src/txBuilder.ts index 7469db2..0738a75 100644 --- a/src/txBuilder.ts +++ b/src/txBuilder.ts @@ -14,6 +14,7 @@ import type { StellarSplitClientConfig } from "./client.js"; import { signTransaction } from "./wallet.js"; import { SimulationFailedError, TransactionFailedError, TransactionNotConfirmedError } from "./errors.js"; import { checkInvoiceExpiry } from "./preflightChecker.js"; +import type { LedgerCloseEstimator } from "./ledgerCloseEstimator.js"; /** Builder for composing multi-operation StellarSplit transactions. */ export class StellarSplitTxBuilder { @@ -23,6 +24,7 @@ export class StellarSplitTxBuilder { private readonly sourceAddress: string; private readonly operations: xdr.Operation[] = []; private _surgeConfig?: FeeSurgeConfig; + private _ledgerEstimator?: LedgerCloseEstimator; constructor(config: StellarSplitClientConfig, sourceAddress: string) { this.config = config; @@ -42,6 +44,34 @@ export class StellarSplitTxBuilder { return this; } + /** + * Attach a {@link LedgerCloseEstimator} to derive precise timebounds from + * ledger sequence numbers rather than fixed wall-clock offsets. + * + * When set, you can pass `targetLedger` to {@link build} / {@link submit} + * and the builder will compute `timebounds.maxTime` via the estimator. + * + * @param estimator - A calibrated {@link LedgerCloseEstimator}. + */ + setLedgerEstimator(estimator: LedgerCloseEstimator): this { + this._ledgerEstimator = estimator; + return this; + } + + /** + * Compute a wall-clock `expiresAt` (Unix seconds) from a target ledger + * sequence, using the attached {@link LedgerCloseEstimator}. + * Returns `undefined` when no estimator has been set. + * + * @param targetLedger - The ledger sequence after which the tx should expire. + */ + expiresAtFromLedger(targetLedger: number): number | undefined { + if (!this._ledgerEstimator) return undefined; + return Math.floor( + this._ledgerEstimator.estimateCloseTime(targetLedger).getTime() / 1000, + ); + } + addPay(invoiceId: string, amount: bigint | number | string): this { const op = this.contract.call( "pay", diff --git a/test/ledgerCloseEstimator.test.ts b/test/ledgerCloseEstimator.test.ts new file mode 100644 index 0000000..5daf27f --- /dev/null +++ b/test/ledgerCloseEstimator.test.ts @@ -0,0 +1,314 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { LedgerCloseEstimator } from "../src/ledgerCloseEstimator.js"; +import { DeadlineEngine } from "../src/deadlineEngine.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Build a fake Horizon.Server stub that returns `records` from `.ledgers()`. + */ +function makeFakeHorizon(records: Array<{ sequence: number; closed_at: string }>) { + return { + ledgers: vi.fn().mockReturnValue({ + order: vi.fn().mockReturnThis(), + limit: vi.fn().mockReturnThis(), + call: vi.fn().mockResolvedValue({ records }), + }), + }; +} + +/** + * Build a series of `count` ledger records starting at `baseSequence`, + * each separated by `intervalMs` milliseconds starting from `baseTimeMs`. + */ +function buildLedgerRecords( + count: number, + baseSequence: number, + baseTimeMs: number, + intervalMs: number, +): Array<{ sequence: number; closed_at: string }> { + return Array.from({ length: count }, (_, i) => ({ + sequence: baseSequence + i, + closed_at: new Date(baseTimeMs + i * intervalMs).toISOString(), + })); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("LedgerCloseEstimator", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + describe("calibrate()", () => { + it("computes the correct rolling-average interval from uniform records", async () => { + const INTERVAL_MS = 5_000; + const BASE_SEQ = 1_000_000; + const BASE_TIME = new Date("2025-01-01T00:00:00Z").getTime(); + const records = buildLedgerRecords(20, BASE_SEQ, BASE_TIME, INTERVAL_MS); + + const estimator = new LedgerCloseEstimator({ + horizonUrl: "https://horizon-testnet.stellar.org", + calibrationIntervalMs: 0, // disable auto-timer for tests + }); + + // Patch _fetchLedgers to return our fake records (sorted desc as Horizon would) + const fetchSpy = vi + .spyOn(estimator as any, "_fetchLedgers") + .mockResolvedValue([...records].reverse()); + + await estimator.calibrate(20); + + expect(fetchSpy).toHaveBeenCalledWith(20); + expect(estimator.state).not.toBeNull(); + expect(estimator.avgIntervalMs).toBeCloseTo(INTERVAL_MS, 0); + + estimator.destroy(); + }); + + it("computes correct average when intervals vary", async () => { + // Intervals: 4s, 6s, 5s, 5s → avg = 5s + const BASE_SEQ = 500_000; + const BASE_TIME = new Date("2025-06-01T12:00:00Z").getTime(); + const records = [ + { sequence: BASE_SEQ, closed_at: new Date(BASE_TIME).toISOString() }, + { sequence: BASE_SEQ + 1, closed_at: new Date(BASE_TIME + 4_000).toISOString() }, + { sequence: BASE_SEQ + 2, closed_at: new Date(BASE_TIME + 10_000).toISOString() }, + { sequence: BASE_SEQ + 3, closed_at: new Date(BASE_TIME + 15_000).toISOString() }, + { sequence: BASE_SEQ + 4, closed_at: new Date(BASE_TIME + 20_000).toISOString() }, + ]; + + const estimator = new LedgerCloseEstimator({ + horizonUrl: "https://horizon-testnet.stellar.org", + calibrationIntervalMs: 0, + }); + + vi.spyOn(estimator as any, "_fetchLedgers").mockResolvedValue(records); + + await estimator.calibrate(5); + + // deltas: 4000, 6000, 5000, 5000 → avg = 5000 + expect(estimator.avgIntervalMs).toBeCloseTo(5_000, 0); + + estimator.destroy(); + }); + + it("uses default sampleSize of 20 when none is provided", async () => { + const estimator = new LedgerCloseEstimator({ + horizonUrl: "https://horizon-testnet.stellar.org", + calibrationIntervalMs: 0, + }); + + const fetchSpy = vi + .spyOn(estimator as any, "_fetchLedgers") + .mockResolvedValue(buildLedgerRecords(20, 1_000, Date.now(), 5_000)); + + await estimator.calibrate(); // no arg → default 20 + + expect(fetchSpy).toHaveBeenCalledWith(20); + + estimator.destroy(); + }); + + it("does not throw when fewer than 2 records are returned", async () => { + const estimator = new LedgerCloseEstimator({ + horizonUrl: "https://horizon-testnet.stellar.org", + calibrationIntervalMs: 0, + }); + + vi.spyOn(estimator as any, "_fetchLedgers").mockResolvedValue([ + { sequence: 100, closed_at: new Date().toISOString() }, + ]); + + await expect(estimator.calibrate()).resolves.toBeUndefined(); + expect(estimator.state).toBeNull(); + + estimator.destroy(); + }); + }); + + describe("estimateCloseTime()", () => { + it("projects the correct future close time given a known interval", async () => { + const INTERVAL_MS = 5_000; + const BASE_SEQ = 2_000_000; + const BASE_TIME = new Date("2025-01-01T00:00:00Z").getTime(); + + const estimator = new LedgerCloseEstimator({ + horizonUrl: "https://horizon-testnet.stellar.org", + calibrationIntervalMs: 0, + }); + + vi.spyOn(estimator as any, "_fetchLedgers").mockResolvedValue( + buildLedgerRecords(20, BASE_SEQ, BASE_TIME, INTERVAL_MS), + ); + + await estimator.calibrate(20); + + const state = estimator.state!; + // 10 ledgers ahead of the latest → 50 s later + const target = state.latestSequence + 10; + const estimated = estimator.estimateCloseTime(target); + + expect(estimated.getTime()).toBeCloseTo( + state.latestClosedAtMs + 10 * INTERVAL_MS, + -2, // within 100ms + ); + + estimator.destroy(); + }); + + it("uses FALLBACK_INTERVAL_MS when not calibrated", () => { + const estimator = new LedgerCloseEstimator({ + horizonUrl: "https://horizon-testnet.stellar.org", + calibrationIntervalMs: 0, + }); + + // spy to stabilize guessCurrentLedger + const currentLedger = 1_000_000; + vi.spyOn(estimator as any, "_guessCurrentLedger").mockReturnValue( + currentLedger, + ); + + const futureTime = estimator.estimateCloseTime(currentLedger + 10); + const expectedMs = Date.now() + 10 * LedgerCloseEstimator.FALLBACK_INTERVAL_MS; + + expect(futureTime.getTime()).toBeCloseTo(expectedMs, -2); + + estimator.destroy(); + }); + + it("returns a past time when targetLedger < latestSequence", async () => { + const INTERVAL_MS = 5_000; + const BASE_SEQ = 3_000_000; + const BASE_TIME = new Date("2025-03-01T00:00:00Z").getTime(); + + const estimator = new LedgerCloseEstimator({ + horizonUrl: "https://horizon-testnet.stellar.org", + calibrationIntervalMs: 0, + }); + + vi.spyOn(estimator as any, "_fetchLedgers").mockResolvedValue( + buildLedgerRecords(20, BASE_SEQ, BASE_TIME, INTERVAL_MS), + ); + + await estimator.calibrate(20); + + const state = estimator.state!; + const pastLedger = state.latestSequence - 5; + const estimated = estimator.estimateCloseTime(pastLedger); + + expect(estimated.getTime()).toBeLessThan(state.latestClosedAtMs); + + estimator.destroy(); + }); + }); + + describe("estimateLedgerAtTime()", () => { + it("projects the correct ledger sequence for a future time", async () => { + const INTERVAL_MS = 5_000; + const BASE_SEQ = 4_000_000; + const BASE_TIME = new Date("2025-05-01T00:00:00Z").getTime(); + + const estimator = new LedgerCloseEstimator({ + horizonUrl: "https://horizon-testnet.stellar.org", + calibrationIntervalMs: 0, + }); + + vi.spyOn(estimator as any, "_fetchLedgers").mockResolvedValue( + buildLedgerRecords(20, BASE_SEQ, BASE_TIME, INTERVAL_MS), + ); + + await estimator.calibrate(20); + + const state = estimator.state!; + // 30 seconds after latest → ~6 ledgers ahead + const futureMs = state.latestClosedAtMs + 30_000; + const seq = estimator.estimateLedgerAtTime(new Date(futureMs)); + + expect(seq).toBeCloseTo(state.latestSequence + 6, 0); + + estimator.destroy(); + }); + + it("uses FALLBACK_INTERVAL_MS when not calibrated", () => { + const estimator = new LedgerCloseEstimator({ + horizonUrl: "https://horizon-testnet.stellar.org", + calibrationIntervalMs: 0, + }); + + const currentLedger = 1_000_000; + vi.spyOn(estimator as any, "_guessCurrentLedger").mockReturnValue( + currentLedger, + ); + + // 50 seconds in the future → 10 ledgers at 5s each + const future = new Date(Date.now() + 50_000); + const seq = estimator.estimateLedgerAtTime(future); + + expect(seq).toBeCloseTo(currentLedger + 10, 0); + + estimator.destroy(); + }); + }); + + describe("auto-calibration", () => { + it("re-calibrates automatically after calibrationIntervalMs", async () => { + const estimator = new LedgerCloseEstimator({ + horizonUrl: "https://horizon-testnet.stellar.org", + calibrationIntervalMs: 60_000, + }); + + const fetchSpy = vi + .spyOn(estimator as any, "_fetchLedgers") + .mockResolvedValue(buildLedgerRecords(20, 1_000, Date.now(), 5_000)); + + await estimator.calibrate(20); + expect(fetchSpy).toHaveBeenCalledTimes(1); + + // Advance fake timers by 1 minute to trigger recalibration + await vi.advanceTimersByTimeAsync(60_000); + + expect(fetchSpy).toHaveBeenCalledTimes(2); + + estimator.destroy(); + }); + }); + + describe("DeadlineEngine.estimateDeadlineFromLedger()", () => { + it("returns a Unix-seconds deadline derived from the estimator", async () => { + const INTERVAL_MS = 5_000; + const BASE_SEQ = 5_000_000; + const BASE_TIME = new Date("2025-07-01T00:00:00Z").getTime(); + + const estimator = new LedgerCloseEstimator({ + horizonUrl: "https://horizon-testnet.stellar.org", + calibrationIntervalMs: 0, + }); + + vi.spyOn(estimator as any, "_fetchLedgers").mockResolvedValue( + buildLedgerRecords(20, BASE_SEQ, BASE_TIME, INTERVAL_MS), + ); + + await estimator.calibrate(20); + const state = estimator.state!; + + const engine = new DeadlineEngine(); + const targetLedger = state.latestSequence + 12; + const deadline = engine.estimateDeadlineFromLedger(targetLedger, estimator); + + const expectedMs = state.latestClosedAtMs + 12 * INTERVAL_MS; + expect(deadline).toBeCloseTo(expectedMs / 1000, 0); + + estimator.destroy(); + }); + }); +});