Skip to content

Commit 329e18c

Browse files
authored
Merge pull request #573 from Prasiejames/main
feat: four SDK improvements — split ratio validator, trustline checker, XDR parser, fee surge detector
2 parents 738f51c + d0d3602 commit 329e18c

9 files changed

Lines changed: 931 additions & 14 deletions

File tree

src/client.ts

Lines changed: 81 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,12 @@ import type { RequestPriority } from "./priorityQueue.js";
232232
import { IdempotencyManager } from "./idempotency.js";
233233
import type { IdempotencyConfig } from "./idempotency.js";
234234
import { validateInvoicePayload } from "./payloadGuard.js";
235+
import { validateSplitRatiosOrThrow } from "./validators/splitRatioValidator.js";
236+
import type { SplitConfig } from "./types.js";
237+
import { checkTrustlines } from "./trustlineChecker.js";
238+
import type { TrustlineCheckResult } from "./trustlineChecker.js";
239+
import { parseEnvelope } from "./xdrParser.js";
240+
import type { ParsedEnvelope } from "./xdrParser.js";
235241
import type { PayloadGuardConfig } from "./payloadGuard.js";
236242
import { HorizonFallbackReader } from "./horizonFallback.js";
237243
import type {
@@ -471,17 +477,16 @@ export interface StellarSplitClientConfig {
471477
/** Optional tuning for the {@link RpcLoadBalancer} created from `rpcEndpoints`. */
472478
rpcLoadBalancer?: RpcLoadBalancerOptions;
473479
/**
474-
* Optional OpenTelemetry instrumentation. When `enabled`, every public
475-
* SplitClient method is wrapped in a span (with `stellar.network`,
476-
* `invoice.id`, `rpc.url`, `rpc.duration_ms`, and `tx.hash` attributes
477-
* where applicable) and three metrics are recorded:
478-
* `split_sdk.rpc_call.count`, `split_sdk.rpc_call.duration`, and
479-
* `split_sdk.tx.error.count`. Fully opt-in and zero-overhead when
480-
* omitted/disabled -- `@opentelemetry/api` is never required unless this
481-
* is turned on. Named `otel` (not `telemetry`) to avoid colliding with
482-
* the pre-existing anonymous-usage `telemetry` option above.
483-
*/
484-
otel?: TelemetryOptions;
480+
* Optional fee surge detector configuration for surge-aware fee adjustment.
481+
* When enabled, fees are adjusted dynamically during network congestion
482+
* based on live Horizon fee statistics.
483+
*/
484+
feeSurgeConfig?: import("./feeSurgeDetector.js").FeeSurgeConfig;
485+
/**
486+
* When true, enables debug helpers such as {@link StellarSplitClient.parseXdrEnvelope}
487+
* for inspecting in-flight transaction envelopes. Defaults to false.
488+
*/
489+
debug?: boolean;
485490
}
486491

487492
/** Network configuration. */
@@ -1721,6 +1726,27 @@ export class StellarSplitClient extends TypedEventEmitter<SplitClientEventMap> {
17211726
return `${baseUrl}/sse${path}`;
17221727
}
17231728

1729+
// ---------------------------------------------------------------------------
1730+
// Debug helpers
1731+
// ---------------------------------------------------------------------------
1732+
1733+
/**
1734+
* Decode a base64-encoded Stellar transaction envelope XDR into a structured,
1735+
* human-readable object. Useful for debugging, audit logging, and UI display.
1736+
*
1737+
* Only functional when {@link StellarSplitClientConfig.debug} is true;
1738+
* otherwise returns a placeholder indicating debug mode is off.
1739+
*
1740+
* @param xdrBase64 - Base64-encoded transaction envelope XDR.
1741+
* @returns A parsed envelope, or a notice when debug mode is disabled.
1742+
*/
1743+
parseXdrEnvelope(xdrBase64: string): ParsedEnvelope | { error: string } {
1744+
if (!this.config.debug) {
1745+
return { error: "Debug mode is disabled. Set config.debug = true to enable XDR parsing." };
1746+
}
1747+
return parseEnvelope(xdrBase64);
1748+
}
1749+
17241750
// ---------------------------------------------------------------------------
17251751
// Public API
17261752
// ---------------------------------------------------------------------------
@@ -1748,6 +1774,21 @@ export class StellarSplitClient extends TypedEventEmitter<SplitClientEventMap> {
17481774
validateInvoicePayload(params, this.config.payloadGuard);
17491775
}
17501776

1777+
// Pre-submission split ratio validation: catch malformed ratio arrays
1778+
// early (ratio-sum violations, negative shares, duplicates, zeros).
1779+
if (params.recipients.length > 1) {
1780+
const total = params.recipients.reduce((s, r) => s + r.amount, 0n);
1781+
if (total > 0n) {
1782+
const splitConfig: SplitConfig = {
1783+
shares: params.recipients.map((r) => ({
1784+
address: r.address,
1785+
share: Number(r.amount) / Number(total),
1786+
})),
1787+
};
1788+
validateSplitRatiosOrThrow(splitConfig);
1789+
}
1790+
}
1791+
17511792
const gate = await this.checkNftGate(params.creator);
17521793
if (gate.gated && !gate.hasNft) {
17531794
throw new NftGateRequiredError(
@@ -3366,7 +3407,35 @@ export class StellarSplitClient extends TypedEventEmitter<SplitClientEventMap> {
33663407
}
33673408
}
33683409

3369-
return computePaymentValidation(invoice, amount, balance);
3410+
const result = computePaymentValidation(invoice, amount, balance);
3411+
3412+
// Add trustline-check results for non-XLM assets when the config has a
3413+
// horizon URL set.
3414+
if (this.config.horizonUrl && invoice.token !== "native") {
3415+
try {
3416+
const { Horizon } = await import("@stellar/stellar-sdk");
3417+
const horizon = new Horizon.Server(this.config.horizonUrl);
3418+
const recipients = invoice.recipients.map((r) => r.address);
3419+
const trustResult = await checkTrustlines(
3420+
horizon,
3421+
recipients,
3422+
invoice.token,
3423+
);
3424+
if (!trustResult.allReady) {
3425+
const missing = trustResult.entries.filter((e) => !e.hasTrustline);
3426+
for (const m of missing) {
3427+
result.errors.push(
3428+
`Recipient ${m.address} has no trustline for token ${invoice.token}. Establish a trustline before releasing.`,
3429+
);
3430+
}
3431+
result.valid = false;
3432+
}
3433+
} catch {
3434+
// Trustline check failed — don't block payment, just skip.
3435+
}
3436+
}
3437+
3438+
return result;
33703439
}
33713440

33723441
private async _getPayerAddress(): Promise<string | null> {

src/feeEstimator.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
1+
export { detectFeeSurge, clearFeeSurgeCache } from "./feeSurgeDetector.js";
2+
export type { FeeSurgeConfig, FeeRecommendation, CongestionLevel } from "./feeSurgeDetector.js";
3+
14
/**
25
* Fee estimator for operations using RPC simulation.
36
*
47
* Estimates operation costs without submitting transactions.
8+
*
9+
* For surge-aware fee estimation, use {@link detectFeeSurge} from
10+
* `./feeSurgeDetector.js`.
511
*/
612

713
import {

src/feeSurgeDetector.ts

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
/**
2+
* Fee surge detector — monitors real-time ledger fee statistics via Horizon
3+
* and automatically recommends an adjusted fee multiplier during network
4+
* congestion so transactions don't fail with hard-coded fee values.
5+
*
6+
* Extends {@link src/feeEstimator.ts} and {@link src/fee.ts} with surge-aware
7+
* behaviour.
8+
*/
9+
10+
import { rpc as SorobanRpc, Horizon } from "@stellar/stellar-sdk";
11+
12+
// ---------------------------------------------------------------------------
13+
// Types
14+
// ---------------------------------------------------------------------------
15+
16+
/**
17+
* Configuration for the fee surge detector.
18+
*/
19+
export interface FeeSurgeConfig {
20+
/**
21+
* Fee percentile to track as the "current" network fee.
22+
*
23+
* - `"p10"` — conservative (lowest fee that 90 % of ledgers accept).
24+
* - `"p50"` — median fee.
25+
* - `"p95"` — aggressive (only 5 % of ledgers require a higher fee).
26+
*
27+
* Defaults to `"p50"`.
28+
*/
29+
percentile?: "p10" | "p50" | "p95";
30+
31+
/**
32+
* Congestion threshold multiplier. When the observed fee exceeds
33+
* `baseFee * surgeMultiplier`, the network is considered congested.
34+
* Defaults to `2`.
35+
*/
36+
surgeMultiplier?: number;
37+
38+
/**
39+
* Recommended fee multiplier applied during surge. Defaults to `1.5`
40+
* (i.e. pay 50 % more than the observed percentile fee during surge).
41+
*/
42+
surgeFeeMultiplier?: number;
43+
44+
/**
45+
* How long a surge recommendation is cached (ms). Defaults to 30_000.
46+
*/
47+
cacheTtlMs?: number;
48+
49+
/**
50+
* Maximum fee in stroops the surge detector will ever recommend. Acts as
51+
* a safety ceiling. Defaults to 10_000_000 (10 XLM).
52+
*/
53+
maxFeeStroops?: number;
54+
}
55+
56+
/** Congestion level derived from fee statistics. */
57+
export type CongestionLevel = "low" | "medium" | "high";
58+
59+
/**
60+
* A fee recommendation produced by the surge detector.
61+
*/
62+
export interface FeeRecommendation {
63+
/** Recommended fee in stroops. */
64+
fee: bigint;
65+
/** The base fee used as reference (in stroops). */
66+
baseFee: bigint;
67+
/** The observed fee-percentile value (in stroops). */
68+
observedFee: bigint;
69+
/** Current congestion level. */
70+
congestion: CongestionLevel;
71+
/** Whether surge pricing is active. */
72+
surgeActive: boolean;
73+
/** Multiplier applied to the base fee. */
74+
multiplier: number;
75+
/** Unix timestamp (ms) when this recommendation was produced. */
76+
timestamp: number;
77+
}
78+
79+
// ---------------------------------------------------------------------------
80+
// Implementation
81+
// ---------------------------------------------------------------------------
82+
83+
const DEFAULT_BASE_FEE = 100n; // 100 stroops
84+
85+
let cachedRecommendation: FeeRecommendation | null = null;
86+
let cacheExpiry = 0;
87+
88+
/**
89+
* Fetch the current fee statistics from Horizon and produce a surge-aware
90+
* fee recommendation.
91+
*
92+
* Uses {@link Horizon.Server.feeStats} which returns a {@link Horizon.FeeStatsResponse}
93+
* with `p10`, `p50`, `p95` fee percentiles.
94+
*
95+
* @param horizonUrl - Horizon server URL (e.g. "https://horizon.stellar.org").
96+
* @param config - Optional surge detector configuration.
97+
* @returns A fee recommendation with congestion level and surge-adjusted fee.
98+
*/
99+
export async function detectFeeSurge(
100+
horizonUrl: string,
101+
config?: FeeSurgeConfig,
102+
): Promise<FeeRecommendation> {
103+
const now = Date.now();
104+
const ttl = config?.cacheTtlMs ?? 30_000;
105+
106+
// Return cached result if still fresh.
107+
if (cachedRecommendation && now < cacheExpiry) {
108+
return cachedRecommendation;
109+
}
110+
111+
const percentile = config?.percentile ?? "p50";
112+
const surgeMultiplier = config?.surgeMultiplier ?? 2;
113+
const surgeFeeMultiplier = config?.surgeFeeMultiplier ?? 1.5;
114+
const maxFee = BigInt(config?.maxFeeStroops ?? 10_000_000);
115+
const baseFee = DEFAULT_BASE_FEE;
116+
117+
try {
118+
const server = new Horizon.Server(horizonUrl);
119+
const feeStats = await server.feeStats();
120+
121+
const observedFee = feePercentileToBigInt(feeStats, percentile);
122+
const surgeActive = observedFee > baseFee * BigInt(Math.ceil(surgeMultiplier));
123+
124+
let congestion: CongestionLevel;
125+
if (observedFee <= baseFee * 2n) {
126+
congestion = "low";
127+
} else if (observedFee <= baseFee * 10n) {
128+
congestion = "medium";
129+
} else {
130+
congestion = "high";
131+
}
132+
133+
let recommendedFee: bigint;
134+
let multiplier: number;
135+
136+
if (surgeActive) {
137+
recommendedFee = BigInt(
138+
Math.ceil(Number(observedFee) * surgeFeeMultiplier),
139+
);
140+
multiplier = surgeFeeMultiplier;
141+
} else {
142+
recommendedFee = observedFee;
143+
multiplier = 1.0;
144+
}
145+
146+
// Apply safety ceiling
147+
if (recommendedFee > maxFee) {
148+
recommendedFee = maxFee;
149+
}
150+
151+
const recommendation: FeeRecommendation = {
152+
fee: recommendedFee,
153+
baseFee,
154+
observedFee,
155+
congestion,
156+
surgeActive,
157+
multiplier,
158+
timestamp: now,
159+
};
160+
161+
// Cache the result
162+
cachedRecommendation = recommendation;
163+
cacheExpiry = now + ttl;
164+
165+
return recommendation;
166+
} catch {
167+
// On failure, return a safe default (base fee with low congestion).
168+
return {
169+
fee: baseFee,
170+
baseFee,
171+
observedFee: baseFee,
172+
congestion: "low",
173+
surgeActive: false,
174+
multiplier: 1.0,
175+
timestamp: now,
176+
};
177+
}
178+
}
179+
180+
/**
181+
* Clear the internal fee recommendation cache so the next call to
182+
* {@link detectFeeSurge} fetches fresh data.
183+
*/
184+
export function clearFeeSurgeCache(): void {
185+
cachedRecommendation = null;
186+
cacheExpiry = 0;
187+
}
188+
189+
/**
190+
* Extract a fee percentile from the Horizon fee stats response as a bigint
191+
* (in stroops).
192+
*/
193+
function feePercentileToBigInt(
194+
stats: any,
195+
percentile: "p10" | "p50" | "p95",
196+
): bigint {
197+
const raw =
198+
percentile === "p10"
199+
? stats.feeCharged.p10
200+
: percentile === "p50"
201+
? stats.feeCharged.p50
202+
: stats.feeCharged.p95;
203+
204+
if (raw === undefined || raw === null) return DEFAULT_BASE_FEE;
205+
// Horizon returns fee values in stroops already. Ceil to the nearest
206+
// integer to avoid floating-point precision issues.
207+
return BigInt(Math.ceil(Number(raw)));
208+
}

src/index.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -807,6 +807,45 @@ export type {
807807
HistoricalInvoiceSample,
808808
} from "./forecast.js";
809809

810+
// ---------------------------------------------------------------------------
811+
// Split ratio validator
812+
// ---------------------------------------------------------------------------
813+
814+
export { validateSplitRatios, validateSplitRatiosOrThrow, ratiosToRecipients } from "./validators/splitRatioValidator.js";
815+
export type {
816+
RecipientShare,
817+
SplitConfig,
818+
SplitRatioValidationResult,
819+
} from "./validators/splitRatioValidator.js";
820+
821+
// ---------------------------------------------------------------------------
822+
// Trustline checker
823+
// ---------------------------------------------------------------------------
824+
825+
export { checkTrustlines, checkSingleTrustline } from "./trustlineChecker.js";
826+
export type { TrustlineEntry, TrustlineCheckResult } from "./trustlineChecker.js";
827+
828+
// ---------------------------------------------------------------------------
829+
// XDR parser
830+
// ---------------------------------------------------------------------------
831+
832+
export { parseEnvelope } from "./xdrParser.js";
833+
export type {
834+
ParsedEnvelope,
835+
ParsedTransaction,
836+
ParsedOperation,
837+
ParsedMemo,
838+
ParsedSignature,
839+
ParsedTimeBounds,
840+
} from "./xdrParser.js";
841+
842+
// ---------------------------------------------------------------------------
843+
// Fee surge detector
844+
// ---------------------------------------------------------------------------
845+
846+
export { detectFeeSurge, clearFeeSurgeCache } from "./feeSurgeDetector.js";
847+
export type { FeeSurgeConfig, FeeRecommendation, CongestionLevel } from "./feeSurgeDetector.js";
848+
810849
export {
811850
reconcileChannel,
812851
registerChannelStateFetcher,

0 commit comments

Comments
 (0)