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
37 changes: 37 additions & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@ import { PriorityQueue } from "./priorityQueue.js";
import type { RequestPriority } from "./priorityQueue.js";
import { IdempotencyManager } from "./idempotency.js";
import type { IdempotencyConfig } from "./idempotency.js";
import { RollbackCoordinator } from "./splitRollbackCoordinator.js";
import { validateInvoicePayload } from "./payloadGuard.js";
import { validateSplitRatiosOrThrow } from "./validators/splitRatioValidator.js";
import type { SplitConfig } from "./types.js";
Expand Down Expand Up @@ -489,6 +490,12 @@ export interface StellarSplitClientConfig {
* for inspecting in-flight transaction envelopes. Defaults to false.
*/
debug?: boolean;
/**
* Optional fiat-to-asset price oracle adapter (see `PriceOracleAdapter` in
* types.ts). Used by `convertFiatToAsset` in currencyConverter.ts to
* resolve display conversions. Defaults to no oracle configured.
*/
priceOracle?: import("./types.js").PriceOracleAdapter;
}

/** Network configuration. */
Expand Down Expand Up @@ -622,6 +629,7 @@ export class StellarSplitClient extends TypedEventEmitter<SplitClientEventMap> {
private _retryOptions: RetryOptions | null = null;
private _horizonReader: HorizonFallbackReader | null = null;
private _idempotency: IdempotencyManager | null = null;
private _rollbackCoordinator: RollbackCoordinator | null = null;
private _pool: ConnectionPool | null = null;
/**
* Effective pool size chosen at construction (or 0 when pooling is off).
Expand Down Expand Up @@ -2119,6 +2127,19 @@ export class StellarSplitClient extends TypedEventEmitter<SplitClientEventMap> {

const result = await this._submitWaterfallTx(params.payer, operations);
this._cache?.invalidate(params.invoiceId);

// Record a rollback checkpoint for the submitted legs. The on-chain
// submission is atomic (all-or-nothing), so every funded step succeeded
// together; downstream app-layer failures (e.g. webhook delivery) are
// reconciled by callers via RollbackCoordinator.markLegFailed.
const coordinator = this.getRollbackCoordinator();
coordinator.begin(
result.txHash,
params.invoiceId,
fundedSteps.map((step) => ({ recipient: step.recipient, amount: step.amount })),
);
fundedSteps.forEach((_, index) => coordinator.markLegSuccess(result.txHash, index));

return { txHash: result.txHash };
}

Expand Down Expand Up @@ -2390,6 +2411,22 @@ export class StellarSplitClient extends TypedEventEmitter<SplitClientEventMap> {
return this._advancedCircuitBreaker;
}

/**
* The rollback coordinator tracking split-payment leg checkpoints created
* by `submitPayment`'s waterfall path. Lazily instantiated on first use.
*/
getRollbackCoordinator(): RollbackCoordinator {
if (!this._rollbackCoordinator) {
this._rollbackCoordinator = new RollbackCoordinator(this._idempotency ?? undefined);
}
return this._rollbackCoordinator;
}

/** The configured fiat-to-asset price oracle adapter, or null if none was provided. */
get priceOracle(): import("./types.js").PriceOracleAdapter | null {
return this.config.priceOracle ?? null;
}

/**
* The optimistic UI cache, or null when `optimisticCache` was not passed
* to the constructor. Use `client.optimisticCache?.onRollback(...)` to
Expand Down
39 changes: 39 additions & 0 deletions src/currencyConverter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,4 +115,43 @@ export async function convertAmount(
fromToken,
toDisplayCurrency,
};
}

/** Result of converting a fiat-denominated invoice amount to an on-chain asset. */
export interface FiatConversion {
/** The requested fiat amount. */
fiatAmount: number;
/** The fiat currency code the amount was denominated in (e.g. "USD"). */
fiatCurrency: string;
/** The equivalent amount in the target asset. */
assetAmount: number;
/** The asset code the amount was converted to (e.g. "XLM"). */
assetCode: string;
/** The base/quote rate used for the conversion (units of `fiatCurrency` per 1 `assetCode`). */
rate: number;
}

/**
* Convert a fiat-denominated amount into its equivalent in `assetCode` using
* a pluggable {@link PriceOracleAdapter} (see priceOracle.ts for the default
* CoinGecko-backed implementation). Display-only, like the rest of this module.
*/
export async function convertFiatToAsset(
fiatAmount: number,
fiatCurrency: string,
assetCode: string,
oracle: PriceOracleAdapter,
): Promise<FiatConversion> {
const rate = await oracle.getPrice(assetCode, fiatCurrency);
if (!(rate > 0)) {
throw new OraclePriceError(`Invalid oracle rate for ${assetCode}/${fiatCurrency}: ${rate}`);
}

return {
fiatAmount,
fiatCurrency,
assetAmount: fiatAmount / rate,
assetCode,
rate,
};
}
30 changes: 30 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,13 @@ export type { SequenceCacheConfig } from "./sequenceCache.js";

export { PathRouter } from "./pathRouter.js";
export type { PathResult, PathHop, PathRequest, PathRouterConfig } from "./pathRouter.js";
export { PathQueryBuilder } from "./pathQueryBuilder.js";
export type {
StrictSendQueryParams,
StrictReceiveQueryParams,
PathQueryBuilderConfig,
} from "./pathQueryBuilder.js";
export type { PathQuery, PathQueryResult, StrictSendPathQuery, StrictReceivePathQuery } from "./types.js";

export { OfferTracker } from "./offerTracker.js";
export type { OfferTrackerConfig, OfferTrackerEventMap } from "./offerTracker.js";
Expand Down Expand Up @@ -778,6 +785,24 @@ export type {
TranchedInvoice,
TrancheStatus,
} from "./trancheProgress.js";

// Invoice payment progress tracking
export { PaymentProgressTracker } from "./paymentProgressTracker.js";
export type {
PaymentProgressEventMap,
PaymentProgressTrackerOptions,
} from "./paymentProgressTracker.js";
export type { InvoicePaymentProgress, RecipientPaymentState } from "./types.js";

// Fiat-to-asset price oracle adapter
export { CoinGeckoPriceOracle } from "./priceOracle.js";
export type { CoinGeckoPriceOracleOptions } from "./priceOracle.js";
export { RateCache } from "./rateCache.js";
export type { RateCacheConfig } from "./rateCache.js";
export type { PriceOracleAdapter } from "./types.js";
export { convertFiatToAsset } from "./currencyConverter.js";
export type { FiatConversion, ConvertedAmount } from "./currencyConverter.js";

export { Sep41Adapter, createSep41Adapter } from "./sep41Adapter.js";
export type { Sep41TokenCapabilities } from "./sep41Adapter.js";

Expand Down Expand Up @@ -900,6 +925,11 @@ export type {
export { IdempotencyManager } from "./idempotency.js";
export type { IdempotencyConfig } from "./idempotency.js";

export { RollbackCoordinator } from "./splitRollbackCoordinator.js";
export type { SplitRollbackEventMap } from "./splitRollbackCoordinator.js";
export type { SplitRollbackRecord } from "./snapshot.js";
export type { SplitLeg, SplitLegState, SplitResult, SplitRollbackCheckpoint } from "./types.js";

export {
validateInvoicePayload,
PayloadSizeError,
Expand Down
171 changes: 171 additions & 0 deletions src/pathQueryBuilder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
/**
* Builds and executes Horizon strict-send / strict-receive path queries.
*
* Encapsulates query assembly and validation so callers (and tests) can work
* with path queries independently of any particular routing strategy.
* {@link PathRouter} (pathRouter.ts) uses this builder internally.
*/

import { Asset, Horizon } from "@stellar/stellar-sdk";
import { SimpleCache } from "./cache.js";
import { InvalidPathQueryError } from "./errors.js";
import { isValidStellarAddress } from "./utils.js";
import type { PathQuery, PathQueryResult } from "./types.js";

/** Parameters for {@link PathQueryBuilder.forStrictSend}. */
export interface StrictSendQueryParams {
/** Asset the sender will supply. */
sourceAsset: Asset;
/** Amount the sender will supply, in the source asset's base unit. */
sourceAmount: bigint;
/** A specific destination account to query reachable assets for. */
destinationAccount?: string;
/** A fixed list of acceptable destination assets. */
destinationAssets?: Asset[];
}

/** Parameters for {@link PathQueryBuilder.forStrictReceive}. */
export interface StrictReceiveQueryParams {
/** Asset the recipient should receive. */
destinationAsset: Asset;
/** Amount the recipient should receive, in the destination asset's base unit. */
destinationAmount: bigint;
/** A specific source account to query spendable assets for. */
sourceAccount?: string;
/** A fixed list of acceptable source assets. */
sourceAssets?: Asset[];
}

/** Configuration for {@link PathQueryBuilder}. */
export interface PathQueryBuilderConfig {
/** Cache TTL in milliseconds. Default: 10_000 (10s). */
ttlMs?: number;
/** Maximum number of cached query results. Default: 5_000. */
maxEntries?: number;
}

/**
* Assembles validated `PathQuery` objects and executes them against Horizon,
* caching results by query parameters within a configurable TTL.
*/
export class PathQueryBuilder {
private readonly server: Horizon.Server;
private readonly cache: SimpleCache<PathQueryResult[]>;

constructor(server: Horizon.Server, config: PathQueryBuilderConfig = {}) {
this.server = server;
this.cache = new SimpleCache<PathQueryResult[]>({
enabled: true,
ttlMs: config.ttlMs ?? 10_000,
maxEntries: config.maxEntries ?? 5_000,
});
}

/**
* Build a strict-send query: `sourceAmount` is fixed, the destination
* amount is estimated. Requires either `destinationAccount` or a non-empty
* `destinationAssets` list.
*/
forStrictSend(params: StrictSendQueryParams): PathQuery {
if (!(params.sourceAmount > 0n)) {
throw new InvalidPathQueryError("sourceAmount must be greater than 0", {
sourceAmount: params.sourceAmount.toString(),
});
}
if (params.destinationAccount === undefined && (!params.destinationAssets || params.destinationAssets.length === 0)) {
throw new InvalidPathQueryError("Either destinationAccount or destinationAssets is required");
}
if (params.destinationAccount !== undefined && !isValidStellarAddress(params.destinationAccount)) {
throw new InvalidPathQueryError(`Invalid destination account: ${params.destinationAccount}`, {
destinationAccount: params.destinationAccount,
});
}

return {
kind: "strictSend",
sourceAsset: params.sourceAsset,
sourceAmount: params.sourceAmount,
destination: params.destinationAccount ?? params.destinationAssets!,
};
}

/**
* Build a strict-receive query: `destinationAmount` is fixed, the source
* amount is estimated. Requires either `sourceAccount` or a non-empty
* `sourceAssets` list.
*/
forStrictReceive(params: StrictReceiveQueryParams): PathQuery {
if (!(params.destinationAmount > 0n)) {
throw new InvalidPathQueryError("destinationAmount must be greater than 0", {
destinationAmount: params.destinationAmount.toString(),
});
}
if (params.sourceAccount === undefined && (!params.sourceAssets || params.sourceAssets.length === 0)) {
throw new InvalidPathQueryError("Either sourceAccount or sourceAssets is required");
}
if (params.sourceAccount !== undefined && !isValidStellarAddress(params.sourceAccount)) {
throw new InvalidPathQueryError(`Invalid source account: ${params.sourceAccount}`, {
sourceAccount: params.sourceAccount,
});
}

return {
kind: "strictReceive",
source: params.sourceAccount ?? params.sourceAssets!,
destinationAsset: params.destinationAsset,
destinationAmount: params.destinationAmount,
};
}

/**
* Submit `query` to Horizon and return every matching path, sorted by
* best rate (highest destination amount for strict-send, lowest source
* amount for strict-receive). Cached by query parameters within the TTL.
*/
async execute(query: PathQuery): Promise<PathQueryResult[]> {
const cacheKey = this.cacheKey(query);
const cached = this.cache.get(cacheKey);
if (cached) return cached;

const records =
query.kind === "strictSend"
? await this.server.strictSendPaths(query.sourceAsset, query.sourceAmount.toString(), query.destination).call()
: await this.server.strictReceivePaths(query.source, query.destinationAsset, query.destinationAmount.toString()).call();

const results: PathQueryResult[] = records.records.map((record) => ({
path: record.path,
sourceAmount: BigInt(record.source_amount),
destinationAmount: BigInt(record.destination_amount),
}));

results.sort((a, b) => {
if (query.kind === "strictSend") {
return a.destinationAmount === b.destinationAmount ? 0 : a.destinationAmount > b.destinationAmount ? -1 : 1;
}
return a.sourceAmount === b.sourceAmount ? 0 : a.sourceAmount < b.sourceAmount ? -1 : 1;
});

this.cache.set(cacheKey, results);
return results;
}

/** Clear all cached query results. */
clearCache(): void {
this.cache.clear();
}

private cacheKey(query: PathQuery): string {
if (query.kind === "strictSend") {
const dest = Array.isArray(query.destination)
? query.destination.map(assetKey).join(",")
: query.destination;
return `strictSend:${assetKey(query.sourceAsset)}:${query.sourceAmount}:${dest}`;
}
const src = Array.isArray(query.source) ? query.source.map(assetKey).join(",") : query.source;
return `strictReceive:${src}:${assetKey(query.destinationAsset)}:${query.destinationAmount}`;
}
}

function assetKey(asset: Asset): string {
return asset.isNative() ? "native" : `${asset.getCode()}:${asset.getIssuer()}`;
}
Loading