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
65 changes: 58 additions & 7 deletions src/claimableBalanceFallback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,12 @@ export interface ClaimableBalanceLifecycleEventMap {
export interface ClaimableBalanceLifecycleConfig {
/** Polling interval in milliseconds. Default: 10_000 (10s). */
pollIntervalMs?: number;
/**
* Time-to-live for tracked entries in milliseconds. Entries older than
* this are considered stale and will be removed by {@link ClaimableBalanceLifecycle.pruneExpired}.
* Default: 86_400_000 (24 hours).
*/
ttlMs?: number;
}

/**
Expand All @@ -280,17 +286,28 @@ export interface ClaimableBalanceLifecycleConfig {
* lifecycle.start();
* ```
*/
/** @internal Extended record stored inside the lifecycle manager. */
interface TrackedEntry {
record: ClaimableBalanceRecord;
/** Unix epoch ms when this entry was registered via {@link ClaimableBalanceLifecycle.track}. */
trackedAt: number;
/** TTL override for this specific entry (ms). Falls back to the manager default. */
ttlMs: number;
}

export class ClaimableBalanceLifecycle extends TypedEventEmitter<ClaimableBalanceLifecycleEventMap> {
private readonly server: Horizon.Server;
private readonly pollIntervalMs: number;
private tracked: Map<string, ClaimableBalanceRecord> = new Map();
private readonly _defaultTtlMs: number;
private tracked: Map<string, TrackedEntry> = new Map();
private pollTimer: ReturnType<typeof setInterval> | null = null;
private _running = false;

constructor(server: Horizon.Server, config: ClaimableBalanceLifecycleConfig = {}) {
super();
this.server = server;
this.pollIntervalMs = config.pollIntervalMs ?? 10_000;
this._defaultTtlMs = config.ttlMs ?? 86_400_000; // 24 h
}

/** Whether the lifecycle manager is currently polling. */
Expand All @@ -303,6 +320,11 @@ export class ClaimableBalanceLifecycle extends TypedEventEmitter<ClaimableBalanc
return this.tracked.size;
}

/** Default TTL for tracked entries in milliseconds. */
get defaultTtlMs(): number {
return this._defaultTtlMs;
}

/**
* Start polling for claimable balance status changes.
*/
Expand All @@ -327,10 +349,37 @@ export class ClaimableBalanceLifecycle extends TypedEventEmitter<ClaimableBalanc
/**
* Register a claimable balance for tracking.
*
* Expired entries are pruned automatically before inserting the new record.
*
* @param record - The balance to track.
* @param ttlMs - Optional per-entry TTL override in milliseconds.
* Falls back to the manager-level default.
*/
track(record: ClaimableBalanceRecord): void {
this.tracked.set(record.balanceId, { ...record });
track(record: ClaimableBalanceRecord, ttlMs?: number): void {
// Prune stale entries before every insert to prevent unbounded growth.
this.pruneExpired();
this.tracked.set(record.balanceId, {
record: { ...record },
trackedAt: Date.now(),
ttlMs: ttlMs ?? this._defaultTtlMs,
});
}

/**
* Remove all tracked entries whose TTL has elapsed.
*
* @returns The number of entries removed.
*/
pruneExpired(): number {
const now = Date.now();
let removed = 0;
for (const [id, entry] of this.tracked.entries()) {
if (now - entry.trackedAt >= entry.ttlMs) {
this.tracked.delete(id);
removed += 1;
}
}
return removed;
}

/**
Expand All @@ -344,7 +393,7 @@ export class ClaimableBalanceLifecycle extends TypedEventEmitter<ClaimableBalanc
* Get all currently tracked balances.
*/
listTracked(): ClaimableBalanceRecord[] {
return Array.from(this.tracked.values());
return Array.from(this.tracked.values()).map((e) => e.record);
}

/**
Expand All @@ -361,13 +410,14 @@ export class ClaimableBalanceLifecycle extends TypedEventEmitter<ClaimableBalanc
networkPassphrase: string,
): Promise<string> {
try {
const record = this.tracked.get(balanceId);
if (!record) {
const entry = this.tracked.get(balanceId);
if (!entry) {
throw new ClaimableBalanceLifecycleError(
`Balance ${balanceId} is not tracked`,
balanceId,
);
}
const record = entry.record;

const keypair = Keypair.fromSecret(claimantSecret);
const account = await this.server.loadAccount(keypair.publicKey());
Expand Down Expand Up @@ -432,7 +482,8 @@ export class ClaimableBalanceLifecycle extends TypedEventEmitter<ClaimableBalanc
private async poll(): Promise<void> {
if (!this._running) return;

for (const [balanceId, record] of this.tracked.entries()) {
for (const [balanceId, entry] of this.tracked.entries()) {
const record = entry.record;
try {
const fresh = await this.server
.claimableBalances()
Expand Down
119 changes: 101 additions & 18 deletions src/queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,31 @@ import { signTransaction } from "./wallet.js";
import type { TxResult } from "./client.js";
import { QueueFailedError } from "./errors.js";

/** Transaction queue for serialized submission. */
/** An item waiting in the priority queue. */
interface QueueItem {
/** Higher priority items are dequeued first. Default is 0. */
priority: number;
/** Insertion order index, used to preserve FIFO among equal-priority items. */
seq: number;
operation: (account: Account) => Promise<{ txHash: string; returnValue: unknown }>;
resolve: (result: TxResult) => void;
reject: (error: unknown) => void;
}

/** Transaction queue for serialized submission with optional priority ordering. */
export class TxQueue {
private server: SorobanRpc.Server;
private networkPassphrase: string;
private sourceAddress: string;
private queue: Promise<TxResult> = Promise.resolve({ txHash: "" });
private failed = false;

/** Pending items sorted by priority (desc) then insertion order (asc). */
private items: QueueItem[] = [];
/** Monotonically increasing sequence counter for FIFO tie-breaking. */
private _seq = 0;
/** Whether the drain loop is currently running. */
private _draining = false;

constructor(
server: SorobanRpc.Server,
networkPassphrase: string,
Expand All @@ -29,35 +46,101 @@ export class TxQueue {
/**
* Enqueue an operation for sequential execution.
*
* @param operation - The operation to execute
* @returns Promise resolving to transaction result
* @param operation - The operation to execute.
* @param priority - Higher values are processed first; equal-priority items
* are processed FIFO. Default: 0.
* @returns Promise resolving to transaction result.
*/
async enqueue(
operation: (
account: Account
) => Promise<{ txHash: string; returnValue: unknown }>
) => Promise<{ txHash: string; returnValue: unknown }>,
priority = 0
): Promise<TxResult> {
if (this.failed) {
throw new QueueFailedError();
}

this.queue = this.queue.then(async () => {
try {
const account = await this.server.getAccount(this.sourceAddress);
const result = await operation(account);
return { txHash: result.txHash };
} catch (error) {
this.failed = true;
throw error;
}
return new Promise<TxResult>((resolve, reject) => {
const item: QueueItem = {
priority,
seq: this._seq++,
operation,
resolve,
reject,
};
this._insert(item);
// Kick off the drain loop if it isn't already running.
void this._drain();
});
}

return this.queue;
/**
* Return the next item that would be dequeued without removing it.
* Returns `undefined` when the queue is empty.
*/
peek(): { priority: number } | undefined {
const head = this.items[0];
if (!head) return undefined;
return { priority: head.priority };
}

/** Clear the queue and reset state. */
/** Clear the queue, reject all pending items, and reset state. */
clear(): void {
this.queue = Promise.resolve({ txHash: "" });
const pending = this.items.splice(0);
for (const item of pending) {
item.reject(new QueueFailedError());
}
this.failed = false;
this._draining = false;
}

// -------------------------------------------------------------------------
// Internal helpers
// -------------------------------------------------------------------------

/** Insert an item in sorted order: higher priority first, FIFO on tie. */
private _insert(item: QueueItem): void {
let lo = 0;
let hi = this.items.length;
while (lo < hi) {
const mid = (lo + hi) >>> 1;
const cand = this.items[mid]!;
// Sorted descending by priority, then ascending by seq
if (
cand.priority > item.priority ||
(cand.priority === item.priority && cand.seq < item.seq)
) {
lo = mid + 1;
} else {
hi = mid;
}
}
this.items.splice(lo, 0, item);
}

/** Sequential drain loop — processes items one at a time. */
private async _drain(): Promise<void> {
if (this._draining) return;
this._draining = true;

while (this.items.length > 0 && !this.failed) {
const item = this.items.shift()!;
try {
const account = await this.server.getAccount(this.sourceAddress);
const result = await item.operation(account);
item.resolve({ txHash: result.txHash });
} catch (error) {
this.failed = true;
item.reject(error);
// Reject all remaining items
const remaining = this.items.splice(0);
for (const r of remaining) {
r.reject(new QueueFailedError());
}
}
}

this._draining = false;
}
}
}
29 changes: 26 additions & 3 deletions src/retryEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@ export interface RetryStrategy {
jitterMs?: number;
}

export interface RetryEngineOptions {
/**
* Multiplicative jitter applied to each computed delay.
* Each delay is multiplied by a random value in [1 - jitterFactor, 1 + jitterFactor].
* Must be in the range [0, 1]. Set to 0 to disable jitter entirely.
* @default 0.2
*/
jitterFactor?: number;
}

export interface RetryConfig {
transient: RetryStrategy;
rateLimit: RetryStrategy;
Expand Down Expand Up @@ -47,11 +57,19 @@ function sleep(ms: number): Promise<void> {
export class RetryEngine {
private _consecutiveTransientFailures = 0;
private _circuitOpenedAt: number | null = null;
private readonly _jitterFactor: number;

constructor(
private readonly config: RetryConfig,
private readonly telemetry: TelemetryCollector
) {}
private readonly telemetry: TelemetryCollector,
options: RetryEngineOptions = {}
) {
const jf = options.jitterFactor ?? 0.2;
if (jf < 0 || jf > 1) {
throw new Error(`jitterFactor must be in [0, 1], got ${jf}`);
}
this._jitterFactor = jf;
}

get isCircuitOpen(): boolean {
if (this._circuitOpenedAt === null) return false;
Expand Down Expand Up @@ -110,10 +128,15 @@ export class RetryEngine {
break;
}

const delay =
const baseDelay =
strategy.initialDelayMs * strategy.backoffMultiplier ** (attempt - 1) +
(strategy.jitterMs ? Math.random() * strategy.jitterMs : 0);

const delay =
this._jitterFactor === 0
? baseDelay
: baseDelay * (1 - this._jitterFactor + Math.random() * 2 * this._jitterFactor);

await sleep(delay);
}
}
Expand Down
27 changes: 27 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2080,3 +2080,30 @@ export interface SubentryCapacityError {
/** The capacity result that triggered this error. */
capacityResult: SubentryCapacityResult;
}

// ---------------------------------------------------------------------------
// Claimable Balance Lifecycle Types
// ---------------------------------------------------------------------------

/** Lifecycle status of a tracked claimable balance. */
export type ClaimableBalanceStatus = "created" | "claimed" | "expired";

/** A claimable balance record tracked by {@link ClaimableBalanceLifecycle}. */
export interface ClaimableBalanceRecord {
/** Stellar claimable balance ID (e.g. `00000000…`). */
balanceId: string;
/** Stellar address of the account that can claim this balance. */
claimant: string;
/** Asset descriptor: `"native"` for XLM, `"CODE:ISSUER"` for issued assets. */
asset: string;
/** Human-readable amount string (e.g. `"12.5000000"`). */
amount: string;
/** Current lifecycle status. */
status: ClaimableBalanceStatus;
/** Unix epoch ms when the balance was created / first tracked. */
createdAt: number;
/** Unix epoch ms when the balance was claimed, or `null` if not yet claimed. */
claimedAt: number | null;
/** Ledger sequence after which the predicate expires (optional). */
predicateExpiryLedger?: number;
}
Loading