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
95 changes: 81 additions & 14 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,12 @@ import type { RequestPriority } from "./priorityQueue.js";
import { IdempotencyManager } from "./idempotency.js";
import type { IdempotencyConfig } from "./idempotency.js";
import { validateInvoicePayload } from "./payloadGuard.js";
import { validateSplitRatiosOrThrow } from "./validators/splitRatioValidator.js";
import type { SplitConfig } from "./types.js";
import { checkTrustlines } from "./trustlineChecker.js";
import type { TrustlineCheckResult } from "./trustlineChecker.js";
import { parseEnvelope } from "./xdrParser.js";
import type { ParsedEnvelope } from "./xdrParser.js";
import type { PayloadGuardConfig } from "./payloadGuard.js";
import { HorizonFallbackReader } from "./horizonFallback.js";
import type {
Expand Down Expand Up @@ -466,17 +472,16 @@ export interface StellarSplitClientConfig {
/** Optional tuning for the {@link RpcLoadBalancer} created from `rpcEndpoints`. */
rpcLoadBalancer?: RpcLoadBalancerOptions;
/**
* Optional OpenTelemetry instrumentation. When `enabled`, every public
* SplitClient method is wrapped in a span (with `stellar.network`,
* `invoice.id`, `rpc.url`, `rpc.duration_ms`, and `tx.hash` attributes
* where applicable) and three metrics are recorded:
* `split_sdk.rpc_call.count`, `split_sdk.rpc_call.duration`, and
* `split_sdk.tx.error.count`. Fully opt-in and zero-overhead when
* omitted/disabled -- `@opentelemetry/api` is never required unless this
* is turned on. Named `otel` (not `telemetry`) to avoid colliding with
* the pre-existing anonymous-usage `telemetry` option above.
*/
otel?: TelemetryOptions;
* Optional fee surge detector configuration for surge-aware fee adjustment.
* When enabled, fees are adjusted dynamically during network congestion
* based on live Horizon fee statistics.
*/
feeSurgeConfig?: import("./feeSurgeDetector.js").FeeSurgeConfig;
/**
* When true, enables debug helpers such as {@link StellarSplitClient.parseXdrEnvelope}
* for inspecting in-flight transaction envelopes. Defaults to false.
*/
debug?: boolean;
}

/** Network configuration. */
Expand Down Expand Up @@ -590,8 +595,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 Expand Up @@ -1718,6 +1721,27 @@ export class StellarSplitClient extends TypedEventEmitter<SplitClientEventMap> {
return `${baseUrl}/sse${path}`;
}

// ---------------------------------------------------------------------------
// Debug helpers
// ---------------------------------------------------------------------------

/**
* Decode a base64-encoded Stellar transaction envelope XDR into a structured,
* human-readable object. Useful for debugging, audit logging, and UI display.
*
* Only functional when {@link StellarSplitClientConfig.debug} is true;
* otherwise returns a placeholder indicating debug mode is off.
*
* @param xdrBase64 - Base64-encoded transaction envelope XDR.
* @returns A parsed envelope, or a notice when debug mode is disabled.
*/
parseXdrEnvelope(xdrBase64: string): ParsedEnvelope | { error: string } {
if (!this.config.debug) {
return { error: "Debug mode is disabled. Set config.debug = true to enable XDR parsing." };
}
return parseEnvelope(xdrBase64);
}

// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -1745,6 +1769,21 @@ export class StellarSplitClient extends TypedEventEmitter<SplitClientEventMap> {
validateInvoicePayload(params, this.config.payloadGuard);
}

// Pre-submission split ratio validation: catch malformed ratio arrays
// early (ratio-sum violations, negative shares, duplicates, zeros).
if (params.recipients.length > 1) {
const total = params.recipients.reduce((s, r) => s + r.amount, 0n);
if (total > 0n) {
const splitConfig: SplitConfig = {
shares: params.recipients.map((r) => ({
address: r.address,
share: Number(r.amount) / Number(total),
})),
};
validateSplitRatiosOrThrow(splitConfig);
}
}

const gate = await this.checkNftGate(params.creator);
if (gate.gated && !gate.hasNft) {
throw new NftGateRequiredError(
Expand Down Expand Up @@ -3352,7 +3391,35 @@ export class StellarSplitClient extends TypedEventEmitter<SplitClientEventMap> {
}
}

return computePaymentValidation(invoice, amount, balance);
const result = computePaymentValidation(invoice, amount, balance);

// Add trustline-check results for non-XLM assets when the config has a
// horizon URL set.
if (this.config.horizonUrl && invoice.token !== "native") {
try {
const { Horizon } = await import("@stellar/stellar-sdk");
const horizon = new Horizon.Server(this.config.horizonUrl);
const recipients = invoice.recipients.map((r) => r.address);
const trustResult = await checkTrustlines(
horizon,
recipients,
invoice.token,
);
if (!trustResult.allReady) {
const missing = trustResult.entries.filter((e) => !e.hasTrustline);
for (const m of missing) {
result.errors.push(
`Recipient ${m.address} has no trustline for token ${invoice.token}. Establish a trustline before releasing.`,
);
}
result.valid = false;
}
} catch {
// Trustline check failed — don't block payment, just skip.
}
}

return result;
}

private async _getPayerAddress(): Promise<string | null> {
Expand Down
6 changes: 6 additions & 0 deletions src/feeEstimator.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
export { detectFeeSurge, clearFeeSurgeCache } from "./feeSurgeDetector.js";
export type { FeeSurgeConfig, FeeRecommendation, CongestionLevel } from "./feeSurgeDetector.js";

/**
* Fee estimator for operations using RPC simulation.
*
* Estimates operation costs without submitting transactions.
*
* For surge-aware fee estimation, use {@link detectFeeSurge} from
* `./feeSurgeDetector.js`.
*/

import {
Expand Down
208 changes: 208 additions & 0 deletions src/feeSurgeDetector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
/**
* Fee surge detector — monitors real-time ledger fee statistics via Horizon
* and automatically recommends an adjusted fee multiplier during network
* congestion so transactions don't fail with hard-coded fee values.
*
* Extends {@link src/feeEstimator.ts} and {@link src/fee.ts} with surge-aware
* behaviour.
*/

import { rpc as SorobanRpc, Horizon } from "@stellar/stellar-sdk";

// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------

/**
* Configuration for the fee surge detector.
*/
export interface FeeSurgeConfig {
/**
* Fee percentile to track as the "current" network fee.
*
* - `"p10"` — conservative (lowest fee that 90 % of ledgers accept).
* - `"p50"` — median fee.
* - `"p95"` — aggressive (only 5 % of ledgers require a higher fee).
*
* Defaults to `"p50"`.
*/
percentile?: "p10" | "p50" | "p95";

/**
* Congestion threshold multiplier. When the observed fee exceeds
* `baseFee * surgeMultiplier`, the network is considered congested.
* Defaults to `2`.
*/
surgeMultiplier?: number;

/**
* Recommended fee multiplier applied during surge. Defaults to `1.5`
* (i.e. pay 50 % more than the observed percentile fee during surge).
*/
surgeFeeMultiplier?: number;

/**
* How long a surge recommendation is cached (ms). Defaults to 30_000.
*/
cacheTtlMs?: number;

/**
* Maximum fee in stroops the surge detector will ever recommend. Acts as
* a safety ceiling. Defaults to 10_000_000 (10 XLM).
*/
maxFeeStroops?: number;
}

/** Congestion level derived from fee statistics. */
export type CongestionLevel = "low" | "medium" | "high";

/**
* A fee recommendation produced by the surge detector.
*/
export interface FeeRecommendation {
/** Recommended fee in stroops. */
fee: bigint;
/** The base fee used as reference (in stroops). */
baseFee: bigint;
/** The observed fee-percentile value (in stroops). */
observedFee: bigint;
/** Current congestion level. */
congestion: CongestionLevel;
/** Whether surge pricing is active. */
surgeActive: boolean;
/** Multiplier applied to the base fee. */
multiplier: number;
/** Unix timestamp (ms) when this recommendation was produced. */
timestamp: number;
}

// ---------------------------------------------------------------------------
// Implementation
// ---------------------------------------------------------------------------

const DEFAULT_BASE_FEE = 100n; // 100 stroops

let cachedRecommendation: FeeRecommendation | null = null;
let cacheExpiry = 0;

/**
* Fetch the current fee statistics from Horizon and produce a surge-aware
* fee recommendation.
*
* Uses {@link Horizon.Server.feeStats} which returns a {@link Horizon.FeeStatsResponse}
* with `p10`, `p50`, `p95` fee percentiles.
*
* @param horizonUrl - Horizon server URL (e.g. "https://horizon.stellar.org").
* @param config - Optional surge detector configuration.
* @returns A fee recommendation with congestion level and surge-adjusted fee.
*/
export async function detectFeeSurge(
horizonUrl: string,
config?: FeeSurgeConfig,
): Promise<FeeRecommendation> {
const now = Date.now();
const ttl = config?.cacheTtlMs ?? 30_000;

// Return cached result if still fresh.
if (cachedRecommendation && now < cacheExpiry) {
return cachedRecommendation;
}

const percentile = config?.percentile ?? "p50";
const surgeMultiplier = config?.surgeMultiplier ?? 2;
const surgeFeeMultiplier = config?.surgeFeeMultiplier ?? 1.5;
const maxFee = BigInt(config?.maxFeeStroops ?? 10_000_000);
const baseFee = DEFAULT_BASE_FEE;

try {
const server = new Horizon.Server(horizonUrl);
const feeStats = await server.feeStats();

const observedFee = feePercentileToBigInt(feeStats, percentile);
const surgeActive = observedFee > baseFee * BigInt(Math.ceil(surgeMultiplier));

let congestion: CongestionLevel;
if (observedFee <= baseFee * 2n) {
congestion = "low";
} else if (observedFee <= baseFee * 10n) {
congestion = "medium";
} else {
congestion = "high";
}

let recommendedFee: bigint;
let multiplier: number;

if (surgeActive) {
recommendedFee = BigInt(
Math.ceil(Number(observedFee) * surgeFeeMultiplier),
);
multiplier = surgeFeeMultiplier;
} else {
recommendedFee = observedFee;
multiplier = 1.0;
}

// Apply safety ceiling
if (recommendedFee > maxFee) {
recommendedFee = maxFee;
}

const recommendation: FeeRecommendation = {
fee: recommendedFee,
baseFee,
observedFee,
congestion,
surgeActive,
multiplier,
timestamp: now,
};

// Cache the result
cachedRecommendation = recommendation;
cacheExpiry = now + ttl;

return recommendation;
} catch {
// On failure, return a safe default (base fee with low congestion).
return {
fee: baseFee,
baseFee,
observedFee: baseFee,
congestion: "low",
surgeActive: false,
multiplier: 1.0,
timestamp: now,
};
}
}

/**
* Clear the internal fee recommendation cache so the next call to
* {@link detectFeeSurge} fetches fresh data.
*/
export function clearFeeSurgeCache(): void {
cachedRecommendation = null;
cacheExpiry = 0;
}

/**
* Extract a fee percentile from the Horizon fee stats response as a bigint
* (in stroops).
*/
function feePercentileToBigInt(
stats: any,
percentile: "p10" | "p50" | "p95",
): bigint {
const raw =
percentile === "p10"
? stats.feeCharged.p10
: percentile === "p50"
? stats.feeCharged.p50
: stats.feeCharged.p95;

if (raw === undefined || raw === null) return DEFAULT_BASE_FEE;
// Horizon returns fee values in stroops already. Ceil to the nearest
// integer to avoid floating-point precision issues.
return BigInt(Math.ceil(Number(raw)));
}
Loading