Skip to content

Commit 81f68ed

Browse files
authored
Merge pull request #574 from micmusjnr20/main
feat: Sequence Cache, Path Router, Offer Tracker & Claimable Balance Lifecycle
2 parents 329e18c + 7894449 commit 81f68ed

7 files changed

Lines changed: 1206 additions & 3 deletions

File tree

src/claimableBalanceFallback.ts

Lines changed: 248 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@
55
* does not exist or has no trustline for the asset, funds would otherwise be
66
* stuck in the contract. This module creates a Stellar claimable balance
77
* instead, letting the payer claim it once their account is ready.
8+
*
9+
* Also provides a lifecycle manager that tracks claimable balances from
10+
* creation through claim/expiry, polling Horizon for status changes and
11+
* emitting typed events.
812
*/
913

1014
import {
@@ -14,10 +18,13 @@ import {
1418
Horizon,
1519
Operation,
1620
TransactionBuilder,
21+
Keypair,
1722
BASE_FEE,
1823
} from "@stellar/stellar-sdk";
1924
import type { StellarSplitClientConfig } from "./client.js";
20-
import { ValidationError } from "./errors.js";
25+
import { ValidationError, ClaimableBalanceLifecycleError } from "./errors.js";
26+
import { TypedEventEmitter, type Unsubscribe } from "./events/TypedEventEmitter.js";
27+
import type { ClaimableBalanceRecord, ClaimableBalanceStatus } from "./types.js";
2128

2229
// ---------------------------------------------------------------------------
2330
// Error-pattern detection
@@ -234,3 +241,243 @@ async function _extractBalanceId(
234241
// Synthetic balance ID: prefixed with zeros so callers can detect it
235242
return `00000000${txHash}`;
236243
}
244+
245+
// ---------------------------------------------------------------------------
246+
// Claimable Balance Lifecycle Manager
247+
// ---------------------------------------------------------------------------
248+
249+
/** Events emitted by {@link ClaimableBalanceLifecycle}. */
250+
export interface ClaimableBalanceLifecycleEventMap {
251+
[key: string]: unknown;
252+
balanceClaimed: ClaimableBalanceRecord;
253+
balanceExpired: ClaimableBalanceRecord;
254+
error: { message: string; error: unknown };
255+
}
256+
257+
/** Configuration for {@link ClaimableBalanceLifecycle}. */
258+
export interface ClaimableBalanceLifecycleConfig {
259+
/** Polling interval in milliseconds. Default: 10_000 (10s). */
260+
pollIntervalMs?: number;
261+
}
262+
263+
/**
264+
* Manages the full create-to-claim lifecycle of claimable balances.
265+
*
266+
* Tracks which balances are pending, detects claims and expirations via
267+
* Horizon polling, and emits typed events so callers can surface the
268+
* lifecycle state to users (e.g. notify recipients to claim, detect
269+
* unclaimed balances past their predicate expiry).
270+
*
271+
* ```ts
272+
* const lifecycle = new ClaimableBalanceLifecycle(horizonServer);
273+
* lifecycle.on("balanceClaimed", (record) => console.log("Claimed:", record));
274+
* lifecycle.on("balanceExpired", (record) => console.log("Expired:", record));
275+
* lifecycle.start();
276+
* ```
277+
*/
278+
export class ClaimableBalanceLifecycle extends TypedEventEmitter<ClaimableBalanceLifecycleEventMap> {
279+
private readonly server: Horizon.Server;
280+
private readonly pollIntervalMs: number;
281+
private tracked: Map<string, ClaimableBalanceRecord> = new Map();
282+
private pollTimer: ReturnType<typeof setInterval> | null = null;
283+
private _running = false;
284+
285+
constructor(server: Horizon.Server, config: ClaimableBalanceLifecycleConfig = {}) {
286+
super();
287+
this.server = server;
288+
this.pollIntervalMs = config.pollIntervalMs ?? 10_000;
289+
}
290+
291+
/** Whether the lifecycle manager is currently polling. */
292+
get running(): boolean {
293+
return this._running;
294+
}
295+
296+
/** Number of balances currently being tracked. */
297+
get trackedCount(): number {
298+
return this.tracked.size;
299+
}
300+
301+
/**
302+
* Start polling for claimable balance status changes.
303+
*/
304+
start(): void {
305+
if (this._running) return;
306+
this._running = true;
307+
this.poll();
308+
this.pollTimer = setInterval(() => this.poll(), this.pollIntervalMs);
309+
}
310+
311+
/**
312+
* Stop polling. Tracked balances are preserved.
313+
*/
314+
stop(): void {
315+
this._running = false;
316+
if (this.pollTimer !== null) {
317+
clearInterval(this.pollTimer);
318+
this.pollTimer = null;
319+
}
320+
}
321+
322+
/**
323+
* Register a claimable balance for tracking.
324+
*
325+
* @param record - The balance to track.
326+
*/
327+
track(record: ClaimableBalanceRecord): void {
328+
this.tracked.set(record.balanceId, { ...record });
329+
}
330+
331+
/**
332+
* Remove a balance from tracking.
333+
*/
334+
untrack(balanceId: string): void {
335+
this.tracked.delete(balanceId);
336+
}
337+
338+
/**
339+
* Get all currently tracked balances.
340+
*/
341+
listTracked(): ClaimableBalanceRecord[] {
342+
return Array.from(this.tracked.values());
343+
}
344+
345+
/**
346+
* Claim a claimable balance on behalf of a recipient.
347+
*
348+
* @param balanceId - The claimable balance ID to claim.
349+
* @param claimantSecret - Secret key of the claimant account.
350+
* @param networkPassphrase - Stellar network passphrase.
351+
* @returns Transaction hash of the claim submission.
352+
*/
353+
async claimBalance(
354+
balanceId: string,
355+
claimantSecret: string,
356+
networkPassphrase: string,
357+
): Promise<string> {
358+
try {
359+
const record = this.tracked.get(balanceId);
360+
if (!record) {
361+
throw new ClaimableBalanceLifecycleError(
362+
`Balance ${balanceId} is not tracked`,
363+
balanceId,
364+
);
365+
}
366+
367+
const keypair = Keypair.fromSecret(claimantSecret);
368+
const account = await this.server.loadAccount(keypair.publicKey());
369+
const sourceAccount = new Account(account.accountId(), account.sequenceNumber());
370+
371+
const tx = new TransactionBuilder(sourceAccount, {
372+
fee: BASE_FEE,
373+
networkPassphrase,
374+
})
375+
.addOperation(
376+
Operation.claimClaimableBalance({
377+
balanceId,
378+
}),
379+
)
380+
.setTimeout(30)
381+
.build();
382+
383+
tx.sign(keypair);
384+
const result = await this.server.submitTransaction(tx);
385+
386+
record.status = "claimed";
387+
record.claimedAt = Date.now();
388+
this.emit("balanceClaimed", record);
389+
this.tracked.delete(balanceId);
390+
391+
return result.hash;
392+
} catch (err) {
393+
if (err instanceof ClaimableBalanceLifecycleError) throw err;
394+
throw new ClaimableBalanceLifecycleError(
395+
`Failed to claim balance ${balanceId}: ${err instanceof Error ? err.message : String(err)}`,
396+
balanceId,
397+
);
398+
}
399+
}
400+
401+
/**
402+
* Query all claimable balances for a given claimant from Horizon.
403+
*
404+
* @param claimant - Stellar address of the claimant.
405+
* @returns List of claimable balance records.
406+
*/
407+
async queryByClaimant(claimant: string): Promise<ClaimableBalanceRecord[]> {
408+
try {
409+
const page = await this.server.claimableBalances().claimant(claimant).call();
410+
return page.records.map((r) => this.toRecord(r, claimant));
411+
} catch {
412+
return [];
413+
}
414+
}
415+
416+
/**
417+
* Manually trigger a poll cycle (useful for testing).
418+
*/
419+
async pollNow(): Promise<void> {
420+
await this.poll();
421+
}
422+
423+
// -----------------------------------------------------------------------
424+
// Internal
425+
// -----------------------------------------------------------------------
426+
427+
private async poll(): Promise<void> {
428+
if (!this._running) return;
429+
430+
for (const [balanceId, record] of this.tracked.entries()) {
431+
try {
432+
const fresh = await this.server
433+
.claimableBalances()
434+
.claimant(record.claimant)
435+
.call();
436+
437+
const found = fresh.records.find((r) => r.id === balanceId);
438+
439+
if (!found) {
440+
// Balance no longer exists — was claimed
441+
record.status = "claimed";
442+
record.claimedAt = record.claimedAt ?? Date.now();
443+
this.emit("balanceClaimed", record);
444+
this.tracked.delete(balanceId);
445+
} else if (record.predicateExpiryLedger) {
446+
// Check if the predicate has expired based on current ledger
447+
// We approximate by checking last_modified_ledger
448+
const currentLedger = found.last_modified_ledger;
449+
if (currentLedger >= record.predicateExpiryLedger) {
450+
record.status = "expired";
451+
this.emit("balanceExpired", record);
452+
this.tracked.delete(balanceId);
453+
}
454+
}
455+
} catch (err) {
456+
this.emit("error", {
457+
message: `Poll error for balance ${balanceId}`,
458+
error: err,
459+
});
460+
}
461+
}
462+
}
463+
464+
private toRecord(
465+
r: Horizon.ServerApi.ClaimableBalanceRecord,
466+
claimant: string,
467+
): ClaimableBalanceRecord {
468+
// Attempt to extract creation time from the record; fall back to Date.now()
469+
const raw = r as unknown as Record<string, unknown>;
470+
const createdAt = raw.last_modified_time
471+
? new Date(raw.last_modified_time as string).getTime()
472+
: Date.now();
473+
return {
474+
balanceId: r.id,
475+
claimant,
476+
asset: r.asset,
477+
amount: r.amount,
478+
status: "created",
479+
createdAt,
480+
claimedAt: null,
481+
};
482+
}
483+
}

src/errors.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1456,6 +1456,126 @@ export class PassphraseMismatchError extends StellarSplitError {
14561456
}
14571457
}
14581458

1459+
// ---------------------------------------------------------------------------
1460+
// Sequence cache errors
1461+
// ---------------------------------------------------------------------------
1462+
1463+
/** Thrown when the sequence cache fails to fetch an account from Horizon. */
1464+
export class SequenceCacheError extends StellarSplitError {
1465+
readonly accountId: string;
1466+
1467+
constructor(message: string, accountId: string) {
1468+
super(message, "SEQUENCE_CACHE_ERROR", { accountId }, message);
1469+
this.name = "SequenceCacheError";
1470+
this.accountId = accountId;
1471+
Object.setPrototypeOf(this, new.target.prototype);
1472+
}
1473+
}
1474+
1475+
export function isSequenceCacheError(err: unknown): err is SequenceCacheError {
1476+
return err instanceof SequenceCacheError;
1477+
}
1478+
1479+
/** Thrown when a SEQUENCE_NUMBER_TOO_OLD error is detected at submission time. */
1480+
export class SequenceNumberTooOldError extends StellarSplitError {
1481+
readonly accountId: string;
1482+
readonly cachedSequence: bigint;
1483+
1484+
constructor(accountId: string, cachedSequence: bigint) {
1485+
super(
1486+
`Sequence number too old for ${accountId} (cached: ${cachedSequence})`,
1487+
"SEQUENCE_NUMBER_TOO_OLD",
1488+
{ accountId, cachedSequence: cachedSequence.toString() },
1489+
);
1490+
this.name = "SequenceNumberTooOldError";
1491+
this.accountId = accountId;
1492+
this.cachedSequence = cachedSequence;
1493+
Object.setPrototypeOf(this, new.target.prototype);
1494+
}
1495+
}
1496+
1497+
export function isSequenceNumberTooOldError(err: unknown): err is SequenceNumberTooOldError {
1498+
return err instanceof SequenceNumberTooOldError;
1499+
}
1500+
1501+
// ---------------------------------------------------------------------------
1502+
// Path router errors
1503+
// ---------------------------------------------------------------------------
1504+
1505+
/** Thrown when no DEX path could be found between two assets. */
1506+
export class PathNotFoundError extends StellarSplitError {
1507+
readonly sourceAsset: string;
1508+
readonly destAsset: string;
1509+
readonly amount: bigint;
1510+
1511+
constructor(sourceAsset: string, destAsset: string, amount: bigint) {
1512+
super(
1513+
`No DEX path found from ${sourceAsset} to ${destAsset} for amount ${amount}`,
1514+
"PATH_NOT_FOUND",
1515+
{ sourceAsset, destAsset, amount: amount.toString() },
1516+
);
1517+
this.name = "PathNotFoundError";
1518+
this.sourceAsset = sourceAsset;
1519+
this.destAsset = destAsset;
1520+
this.amount = amount;
1521+
Object.setPrototypeOf(this, new.target.prototype);
1522+
}
1523+
}
1524+
1525+
export function isPathNotFoundError(err: unknown): err is PathNotFoundError {
1526+
return err instanceof PathNotFoundError;
1527+
}
1528+
1529+
/** Thrown when the path router encounters an unexpected error. */
1530+
export class PathRouterError extends StellarSplitError {
1531+
constructor(message: string) {
1532+
super(message, "PATH_ROUTER_ERROR", undefined, message);
1533+
this.name = "PathRouterError";
1534+
Object.setPrototypeOf(this, new.target.prototype);
1535+
}
1536+
}
1537+
1538+
export function isPathRouterError(err: unknown): err is PathRouterError {
1539+
return err instanceof PathRouterError;
1540+
}
1541+
1542+
// ---------------------------------------------------------------------------
1543+
// Offer tracker errors
1544+
// ---------------------------------------------------------------------------
1545+
1546+
/** Thrown when offer tracking or cancellation fails. */
1547+
export class OfferTrackingError extends StellarSplitError {
1548+
constructor(message: string) {
1549+
super(message, "OFFER_TRACKING_ERROR", undefined, message);
1550+
this.name = "OfferTrackingError";
1551+
Object.setPrototypeOf(this, new.target.prototype);
1552+
}
1553+
}
1554+
1555+
export function isOfferTrackingError(err: unknown): err is OfferTrackingError {
1556+
return err instanceof OfferTrackingError;
1557+
}
1558+
1559+
// ---------------------------------------------------------------------------
1560+
// Claimable balance lifecycle errors
1561+
// ---------------------------------------------------------------------------
1562+
1563+
/** Thrown when claimable balance lifecycle operations fail. */
1564+
export class ClaimableBalanceLifecycleError extends StellarSplitError {
1565+
readonly balanceId: string;
1566+
1567+
constructor(message: string, balanceId: string) {
1568+
super(message, "CLAIMABLE_BALANCE_LIFECYCLE_ERROR", { balanceId }, message);
1569+
this.name = "ClaimableBalanceLifecycleError";
1570+
this.balanceId = balanceId;
1571+
Object.setPrototypeOf(this, new.target.prototype);
1572+
}
1573+
}
1574+
1575+
export function isClaimableBalanceLifecycleError(err: unknown): err is ClaimableBalanceLifecycleError {
1576+
return err instanceof ClaimableBalanceLifecycleError;
1577+
}
1578+
14591579
export function isIPFSConfigError(err: unknown): err is IPFSConfigError {
14601580
return err instanceof IPFSConfigError;
14611581
}

0 commit comments

Comments
 (0)