Skip to content
Open
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
249 changes: 248 additions & 1 deletion src/claimableBalanceFallback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@
* does not exist or has no trustline for the asset, funds would otherwise be
* stuck in the contract. This module creates a Stellar claimable balance
* instead, letting the payer claim it once their account is ready.
*
* Also provides a lifecycle manager that tracks claimable balances from
* creation through claim/expiry, polling Horizon for status changes and
* emitting typed events.
*/

import {
Expand All @@ -14,10 +18,13 @@ import {
Horizon,
Operation,
TransactionBuilder,
Keypair,
BASE_FEE,
} from "@stellar/stellar-sdk";
import type { StellarSplitClientConfig } from "./client.js";
import { ValidationError } from "./errors.js";
import { ValidationError, ClaimableBalanceLifecycleError } from "./errors.js";
import { TypedEventEmitter, type Unsubscribe } from "./events/TypedEventEmitter.js";
import type { ClaimableBalanceRecord, ClaimableBalanceStatus } from "./types.js";

// ---------------------------------------------------------------------------
// Error-pattern detection
Expand Down Expand Up @@ -234,3 +241,243 @@ async function _extractBalanceId(
// Synthetic balance ID: prefixed with zeros so callers can detect it
return `00000000${txHash}`;
}

// ---------------------------------------------------------------------------
// Claimable Balance Lifecycle Manager
// ---------------------------------------------------------------------------

/** Events emitted by {@link ClaimableBalanceLifecycle}. */
export interface ClaimableBalanceLifecycleEventMap {
[key: string]: unknown;
balanceClaimed: ClaimableBalanceRecord;
balanceExpired: ClaimableBalanceRecord;
error: { message: string; error: unknown };
}

/** Configuration for {@link ClaimableBalanceLifecycle}. */
export interface ClaimableBalanceLifecycleConfig {
/** Polling interval in milliseconds. Default: 10_000 (10s). */
pollIntervalMs?: number;
}

/**
* Manages the full create-to-claim lifecycle of claimable balances.
*
* Tracks which balances are pending, detects claims and expirations via
* Horizon polling, and emits typed events so callers can surface the
* lifecycle state to users (e.g. notify recipients to claim, detect
* unclaimed balances past their predicate expiry).
*
* ```ts
* const lifecycle = new ClaimableBalanceLifecycle(horizonServer);
* lifecycle.on("balanceClaimed", (record) => console.log("Claimed:", record));
* lifecycle.on("balanceExpired", (record) => console.log("Expired:", record));
* lifecycle.start();
* ```
*/
export class ClaimableBalanceLifecycle extends TypedEventEmitter<ClaimableBalanceLifecycleEventMap> {
private readonly server: Horizon.Server;
private readonly pollIntervalMs: number;
private tracked: Map<string, ClaimableBalanceRecord> = 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;
}

/** Whether the lifecycle manager is currently polling. */
get running(): boolean {
return this._running;
}

/** Number of balances currently being tracked. */
get trackedCount(): number {
return this.tracked.size;
}

/**
* Start polling for claimable balance status changes.
*/
start(): void {
if (this._running) return;
this._running = true;
this.poll();
this.pollTimer = setInterval(() => this.poll(), this.pollIntervalMs);
}

/**
* Stop polling. Tracked balances are preserved.
*/
stop(): void {
this._running = false;
if (this.pollTimer !== null) {
clearInterval(this.pollTimer);
this.pollTimer = null;
}
}

/**
* Register a claimable balance for tracking.
*
* @param record - The balance to track.
*/
track(record: ClaimableBalanceRecord): void {
this.tracked.set(record.balanceId, { ...record });
}

/**
* Remove a balance from tracking.
*/
untrack(balanceId: string): void {
this.tracked.delete(balanceId);
}

/**
* Get all currently tracked balances.
*/
listTracked(): ClaimableBalanceRecord[] {
return Array.from(this.tracked.values());
}

/**
* Claim a claimable balance on behalf of a recipient.
*
* @param balanceId - The claimable balance ID to claim.
* @param claimantSecret - Secret key of the claimant account.
* @param networkPassphrase - Stellar network passphrase.
* @returns Transaction hash of the claim submission.
*/
async claimBalance(
balanceId: string,
claimantSecret: string,
networkPassphrase: string,
): Promise<string> {
try {
const record = this.tracked.get(balanceId);
if (!record) {
throw new ClaimableBalanceLifecycleError(
`Balance ${balanceId} is not tracked`,
balanceId,
);
}

const keypair = Keypair.fromSecret(claimantSecret);
const account = await this.server.loadAccount(keypair.publicKey());
const sourceAccount = new Account(account.accountId(), account.sequenceNumber());

const tx = new TransactionBuilder(sourceAccount, {
fee: BASE_FEE,
networkPassphrase,
})
.addOperation(
Operation.claimClaimableBalance({
balanceId,
}),
)
.setTimeout(30)
.build();

tx.sign(keypair);
const result = await this.server.submitTransaction(tx);

record.status = "claimed";
record.claimedAt = Date.now();
this.emit("balanceClaimed", record);
this.tracked.delete(balanceId);

return result.hash;
} catch (err) {
if (err instanceof ClaimableBalanceLifecycleError) throw err;
throw new ClaimableBalanceLifecycleError(
`Failed to claim balance ${balanceId}: ${err instanceof Error ? err.message : String(err)}`,
balanceId,
);
}
}

/**
* Query all claimable balances for a given claimant from Horizon.
*
* @param claimant - Stellar address of the claimant.
* @returns List of claimable balance records.
*/
async queryByClaimant(claimant: string): Promise<ClaimableBalanceRecord[]> {
try {
const page = await this.server.claimableBalances().claimant(claimant).call();
return page.records.map((r) => this.toRecord(r, claimant));
} catch {
return [];
}
}

/**
* Manually trigger a poll cycle (useful for testing).
*/
async pollNow(): Promise<void> {
await this.poll();
}

// -----------------------------------------------------------------------
// Internal
// -----------------------------------------------------------------------

private async poll(): Promise<void> {
if (!this._running) return;

for (const [balanceId, record] of this.tracked.entries()) {
try {
const fresh = await this.server
.claimableBalances()
.claimant(record.claimant)
.call();

const found = fresh.records.find((r) => r.id === balanceId);

if (!found) {
// Balance no longer exists — was claimed
record.status = "claimed";
record.claimedAt = record.claimedAt ?? Date.now();
this.emit("balanceClaimed", record);
this.tracked.delete(balanceId);
} else if (record.predicateExpiryLedger) {
// Check if the predicate has expired based on current ledger
// We approximate by checking last_modified_ledger
const currentLedger = found.last_modified_ledger;
if (currentLedger >= record.predicateExpiryLedger) {
record.status = "expired";
this.emit("balanceExpired", record);
this.tracked.delete(balanceId);
}
}
} catch (err) {
this.emit("error", {
message: `Poll error for balance ${balanceId}`,
error: err,
});
}
}
}

private toRecord(
r: Horizon.ServerApi.ClaimableBalanceRecord,
claimant: string,
): ClaimableBalanceRecord {
// Attempt to extract creation time from the record; fall back to Date.now()
const raw = r as unknown as Record<string, unknown>;
const createdAt = raw.last_modified_time
? new Date(raw.last_modified_time as string).getTime()
: Date.now();
return {
balanceId: r.id,
claimant,
asset: r.asset,
amount: r.amount,
status: "created",
createdAt,
claimedAt: null,
};
}
}
2 changes: 0 additions & 2 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -590,8 +590,6 @@ export function verifyCompletionProof(proof: CompletionProof): {
}
return { valid: true };
}
export class StellarSplitClient extends EventEmitter {

export class StellarSplitClient extends TypedEventEmitter<SplitClientEventMap> {
private _mainServer!: SorobanRpc.Server;
private _standby: WarmStandby | null = null;
Expand Down
Loading