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
21 changes: 21 additions & 0 deletions src/deadlineEngine.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { LedgerCloseEstimator } from "./ledgerCloseEstimator.js";

type TimeoutLike = ReturnType<typeof setInterval>;

export interface CountdownOptions {
Expand Down Expand Up @@ -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);
}
}
7 changes: 7 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
276 changes: 276 additions & 0 deletions src/ledgerCloseEstimator.ts
Original file line number Diff line number Diff line change
@@ -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<typeof setInterval> | 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<void> {
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<LedgerRecord[]> {
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)
);
}
}
30 changes: 30 additions & 0 deletions src/txBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
Expand All @@ -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",
Expand Down
Loading