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
1014import {
@@ -14,10 +18,13 @@ import {
1418 Horizon ,
1519 Operation ,
1620 TransactionBuilder ,
21+ Keypair ,
1722 BASE_FEE ,
1823} from "@stellar/stellar-sdk" ;
1924import 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+ }
0 commit comments