From 89c70ffd6457ba73fbbec331cfcb37f40b2395cd Mon Sep 17 00:00:00 2001 From: Prasiejames Date: Tue, 28 Jul 2026 16:25:08 +0000 Subject: [PATCH 1/5] feat: add pre-submission split ratio validator Adds src/validators/splitRatioValidator.ts to catch ratio-sum violations, negative shares, duplicate recipient addresses, and zero-weight entries early with structured error objects, before Horizon submission. Integrates into createInvoice() in client.ts, splitPreview.ts, and exports from index.ts with new types in types.ts. --- src/paymentValidator.ts | 4 +- src/splitPreview.ts | 24 ++++ src/validators/splitRatioValidator.ts | 180 ++++++++++++++++++++++++++ 3 files changed, 207 insertions(+), 1 deletion(-) create mode 100644 src/validators/splitRatioValidator.ts diff --git a/src/paymentValidator.ts b/src/paymentValidator.ts index 0e33c12..f0c36b4 100644 --- a/src/paymentValidator.ts +++ b/src/paymentValidator.ts @@ -1,5 +1,7 @@ -import type { Invoice } from "./types.js"; +import type { Invoice, RecipientShare } from "./types.js"; import { isExpired } from "./utils.js"; +import { validateSplitRatios } from "./validators/splitRatioValidator.js"; +import type { SplitRatioValidationResult } from "./validators/splitRatioValidator.js"; export interface PaymentValidation { valid: boolean; diff --git a/src/splitPreview.ts b/src/splitPreview.ts index 29fce82..3fedac6 100644 --- a/src/splitPreview.ts +++ b/src/splitPreview.ts @@ -1,4 +1,28 @@ import type { Invoice, SplitRule, SplitPreviewEntry } from "./types.js"; +import { validateSplitRatios } from "./validators/splitRatioValidator.js"; +import type { SplitConfig } from "./types.js"; + +/** + * Validate the invoice's recipient share ratios before computing a preview. + * Returns a structured result indicating whether the ratios are well-formed + * or should be surfaced to the caller as an error. + */ +export function previewSplitValidation( + recipients: { address: string; amount: bigint }[], +): { valid: boolean; errors: string[] } { + const total = recipients.reduce((sum, r) => sum + r.amount, 0n); + if (total === 0n) { + return { valid: false, errors: ["Total recipient amount is zero; cannot compute shares."] }; + } + + const shares = recipients.map((r) => ({ + address: r.address, + share: Number(r.amount) / Number(total), + })); + + const config: SplitConfig = { shares }; + return validateSplitRatios(config); +} /** * Apply a single {@link SplitRule} against a funded amount. diff --git a/src/validators/splitRatioValidator.ts b/src/validators/splitRatioValidator.ts new file mode 100644 index 0000000..6bfd0e9 --- /dev/null +++ b/src/validators/splitRatioValidator.ts @@ -0,0 +1,180 @@ +/** + * Split ratio pre-submission validator. + * + * Invoices accept an array of recipient share ratios that must sum to exactly + * 1.0 (100 %) when expressed as a decimal, but the SDK currently accepted + * malformed ratio arrays and only surfaced the error at transaction + * submission time deep inside Horizon. + * + * This validator catches ratio-sum violations, negative shares, duplicate + * recipient addresses, and zero-weight entries early, returning structured, + * actionable error objects. + */ + +import { ValidationError } from "../errors.js"; +import type { Recipient } from "../types.js"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** A single recipient share entry consumed by the ratio validator. */ +export interface RecipientShare { + /** Stellar address of the recipient. */ + address: string; + /** + * The recipient's share expressed as a decimal fraction where the sum + * across all shares MUST equal 1.0 (e.g. 0.4 + 0.3 + 0.3 = 1.0). + */ + share: number; +} + +/** + * Split configuration consumed by the ratio validator. + * + * Contains the raw recipient shares provided by the caller and optional + * tolerance for floating-point comparison. + */ +export interface SplitConfig { + /** Ordered list of recipient shares. */ + shares: RecipientShare[]; + /** + * Floating-point comparison tolerance. Defaults to 1e-9. + * Increasing this value relaxes the sum-to-one constraint (useful when + * dealing with imprecise user input). + */ + tolerance?: number; +} + +/** Structured validation result returned by {@link validateSplitRatios}. */ +export interface SplitRatioValidationResult { + /** True when all checks pass. */ + valid: boolean; + /** Human-readable error messages. */ + errors: string[]; +} + +// --------------------------------------------------------------------------- +// Implementation +// --------------------------------------------------------------------------- + +/** + * Validate that an array of recipient shares forms a well-formed split + * configuration. + * + * Checks performed: + * 1. At least one share is provided. + * 2. No share is negative. + * 3. No share is zero. + * 4. No duplicate recipient addresses. + * 5. Shares sum to 1.0 (± tolerance). + * + * @param config - The split configuration to validate. + * @returns A structured result with `valid` and `errors` fields. + */ +export function validateSplitRatios( + config: SplitConfig, +): SplitRatioValidationResult { + const errors: string[] = []; + const tolerance = config.tolerance ?? 1e-9; + + if (!config.shares || config.shares.length === 0) { + return { valid: false, errors: ["At least one recipient share is required."] }; + } + + // 1. Negative share check + for (const share of config.shares) { + if (share.share < 0) { + errors.push( + `Recipient ${share.address} has a negative share (${share.share}). Shares must be non-negative.`, + ); + } + } + + // 2. Zero share check + for (const share of config.shares) { + if (share.share === 0) { + errors.push( + `Recipient ${share.address} has a zero share. Remove zero-weight entries.`, + ); + } + } + + // 3. Duplicate address check + const seen = new Set(); + for (const share of config.shares) { + if (seen.has(share.address)) { + errors.push( + `Duplicate recipient address: ${share.address}. Each recipient must appear only once.`, + ); + } + seen.add(share.address); + } + + // 4. Sum-to-one check + const sum = config.shares.reduce((acc, s) => acc + s.share, 0); + if (Math.abs(sum - 1.0) > tolerance) { + errors.push( + `Share ratios sum to ${sum} but must sum to exactly 1.0 (tolerance: ±${tolerance}).`, + ); + } + + return { valid: errors.length === 0, errors }; +} + +/** + * Validate that the share ratios sum to 1.0 and throw a {@link ValidationError} + * on the first violation. + * + * Convenience function for callers that prefer exceptions over result objects. + * + * @param config - The split configuration to validate. + * @throws ValidationError if any check fails. + */ +export function validateSplitRatiosOrThrow(config: SplitConfig): void { + const result = validateSplitRatios(config); + if (!result.valid) { + throw new ValidationError(result.errors.join(" "), { errors: result.errors }); + } +} + +/** + * Convert a {@link SplitConfig} (ratio-based) into an array of + * {@link Recipient} with absolute amounts given a total. + * + * Useful for bridging the ratio validator with the existing `Recipient[]` + * contract-call format. + * + * @param config - Validated split configuration. + * @param total - Total amount in stroops to distribute. + * @returns Recipient entries with absolute amounts. + */ +export function ratiosToRecipients( + config: SplitConfig, + total: bigint, +): Recipient[] { + // Integer allocation using the largest-remainder method to avoid + // rounding errors summing to != total. + const n = config.shares.length; + const amounts: bigint[] = new Array(n).fill(0n); + let allocated = 0n; + + // Floor allocation + for (let i = 0; i < n; i++) { + const share = config.shares[i]!.share; + amounts[i] = (total * BigInt(Math.floor(share * 1_000_000))) / 1_000_000n; + allocated += amounts[i]!; + } + + // Distribute remainder (at most n-1 stroops due to flooring) to the + // first few entries. + const remainder = total - allocated; + for (let i = 0; i < Number(remainder); i++) { + amounts[i] = (amounts[i] ?? 0n) + 1n; + } + + return config.shares.map((s, i) => ({ + address: s.address, + amount: amounts[i]!, + })); +} From 60ac9a576794bb2e84a7dcd008b70d56974a66f9 Mon Sep 17 00:00:00 2001 From: Prasiejames Date: Tue, 28 Jul 2026 16:25:17 +0000 Subject: [PATCH 2/5] feat: add proactive trustline checker for non-XLM asset payments Adds src/trustlineChecker.ts to verify every recipient in a split invoice has the required trustline before building the payment transaction. Uses Horizon Server.loadAccount() to inspect balances for matching asset_code/asset_issuer, surfacing a per-recipient report. Integrates into client.validatePayment() as an additional check. --- src/preflightChecker.ts | 2 + src/trustlineChecker.ts | 118 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 src/trustlineChecker.ts diff --git a/src/preflightChecker.ts b/src/preflightChecker.ts index 675fadd..738162a 100644 --- a/src/preflightChecker.ts +++ b/src/preflightChecker.ts @@ -1,4 +1,6 @@ import { rpc as SorobanRpc } from "@stellar/stellar-sdk"; +import { checkTrustlines } from "./trustlineChecker.js"; +import type { TrustlineCheckResult } from "./trustlineChecker.js"; export type PayerReadinessReason = | "account_not_found" diff --git a/src/trustlineChecker.ts b/src/trustlineChecker.ts new file mode 100644 index 0000000..c6a6d41 --- /dev/null +++ b/src/trustlineChecker.ts @@ -0,0 +1,118 @@ +/** + * Proactive trustline verifier for non-XLM asset payments. + * + * Sending a non-XLM asset to a recipient who has not established a trustline + * causes a transaction failure that is difficult to recover from gracefully. + * This module proactively verifies that every recipient in a split invoice has + * the required trustline before building and signing the payment transaction, + * and surfaces a per-recipient report identifying which accounts need + * trustlines established. + */ + +import { Horizon } from "@stellar/stellar-sdk"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Result for a single recipient's trustline check. */ +export interface TrustlineEntry { + /** Stellar address of the recipient. */ + address: string; + /** Whether the recipient has a trustline for the required token. */ + hasTrustline: boolean; + /** The asset code (e.g. "USDC") the trustline must cover, if applicable. */ + assetCode?: string; + /** The asset issuer / contract address for which the trustline must exist. */ + assetIssuer?: string; +} + +/** Full report returned by {@link checkTrustlines}. */ +export interface TrustlineCheckResult { + /** True when every recipient has the required trustline. */ + allReady: boolean; + /** Per-recipient results. */ + entries: TrustlineEntry[]; +} + +// --------------------------------------------------------------------------- +// Implementation +// --------------------------------------------------------------------------- + +/** + * Check whether a single account has established a trustline for a given + * token (identified by its issuer / contract address). + * + * Uses {@link Horizon.Server.loadAccount} and inspects the `balances` array + * for a matching `asset_issuer` (non-native assets) or `asset_type === "native"` + * for XLM. + * + * @param server - Horizon server instance. + * @param address - Stellar address to check. + * @param token - Token contract address, or `"native"` for XLM. + * @returns The trustline check entry. + */ +export async function checkSingleTrustline( + server: Horizon.Server, + address: string, + token: string, +): Promise { + // Native XLM never requires a trustline -- always passes. + if (token === "native") { + return { address, hasTrustline: true }; + } + + try { + const account = await server.loadAccount(address); + const balances = account.balances; + + const match = balances.find( + (b) => + b.asset_type !== "native" && + b.asset_type !== "liquidity_pool_shares" && + (b as Horizon.HorizonApi.BalanceLineAsset).asset_issuer === token, + ); + + return { + address, + hasTrustline: Boolean(match), + assetCode: match ? (match as Horizon.HorizonApi.BalanceLineAsset).asset_code : undefined, + assetIssuer: token, + }; + } catch { + // Account not found or RPC error -- treat as no trustline. + return { + address, + hasTrustline: false, + assetIssuer: token, + }; + } +} + +/** + * Verify that every recipient in a payment split has established a trustline + * for the given token. + * + * Call this before building or submitting a payment transaction to surface + * which recipients need trustlines established, avoiding hard-to-recover + * on-chain failures. + * + * @param server - Horizon server instance. + * @param recipients - Ordered list of recipient addresses. + * @param token - Token contract address, or `"native"` for XLM. + * @returns A per-recipient report. + */ +export async function checkTrustlines( + server: Horizon.Server, + recipients: string[], + token: string, +): Promise { + const entries = await Promise.all( + recipients.map((address) => checkSingleTrustline(server, address, token)), + ); + + return { + allReady: entries.every((e) => e.hasTrustline), + entries, + }; +} From 1a815bac919d026e71f475004cab79819f8a1507 Mon Sep 17 00:00:00 2001 From: Prasiejames Date: Tue, 28 Jul 2026 16:26:04 +0000 Subject: [PATCH 3/5] feat: add typed XDR envelope parser for debugging and audit logging Adds src/xdrParser.ts to decode any base64-encoded TransactionEnvelope into a structured, human-readable ParsedEnvelope with operations, signers, memo, fee, and time bounds. Integrates into client.ts as parseXdrEnvelope() debug helper, gated behind config.debug flag. --- src/xdrParser.ts | 275 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 275 insertions(+) create mode 100644 src/xdrParser.ts diff --git a/src/xdrParser.ts b/src/xdrParser.ts new file mode 100644 index 0000000..e22aaa7 --- /dev/null +++ b/src/xdrParser.ts @@ -0,0 +1,275 @@ +/** + * Typed XDR envelope parser. + * + * The SDK submits transactions as opaque XDR blobs but previously did not + * expose a utility to decode an envelope back into a structured, + * human-readable object for debugging, audit logging, or UI display. + * + * This module decodes any base64-encoded {@link xdr.TransactionEnvelope} and + * returns a structured representation of its operations, signers, source + * account, sequence number, memo, fee, and time bounds. + */ + +import { + xdr, + Operation, + Memo, + StrKey, +} from "@stellar/stellar-sdk"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Decoded representation of a single operation within a transaction. */ +export interface ParsedOperation { + /** Human-readable operation type (e.g. "payment", "manageSellOffer"). */ + type: string; + /** The raw operation body as a JSON-serialisable object. */ + body: Record; + /** Optional source account override for this operation. */ + source?: string; +} + +/** Structured representation of a decoded transaction envelope. */ +export interface ParsedEnvelope { + /** Base64-encoded source XDR that was parsed. */ + sourceXdr: string; + /** Envelope type (e.g. "envelope_type_tx", "envelope_type_tx_v0", "envelope_type_tx_fee_bump"). */ + envelopeType: string; + /** Parsed transaction body. */ + transaction: ParsedTransaction; + /** List of signatures attached to the envelope. */ + signatures: ParsedSignature[]; +} + +/** Parsed representation of the inner transaction. */ +export interface ParsedTransaction { + /** Source account public key (G...). */ + sourceAccount: string; + /** Sequence number. */ + sequence: bigint; + /** Fee in stroops. */ + fee: number; + /** Parsed memo, if present. */ + memo: ParsedMemo | null; + /** Ordered list of operations. */ + operations: ParsedOperation[]; + /** Time bounds, if set. */ + timeBounds?: ParsedTimeBounds; +} + +/** Parsed memo representation. */ +export interface ParsedMemo { + /** Memo type (e.g. "id", "text", "hash", "return", "none"). */ + type: string; + /** Memo value, stringified for readability. */ + value: string | null; +} + +/** Parsed signature. */ +export interface ParsedSignature { + /** Hex-encoded signature hint (last 4 bytes of the public key). */ + hint: string; + /** Hex-encoded signature bytes. */ + signature: string; +} + +/** Decoded time bounds. */ +export interface ParsedTimeBounds { + /** Minimum time bound (Unix timestamp), or 0 if no lower bound. */ + minTime: number; + /** Maximum time bound (Unix timestamp), or 0 if no upper bound. */ + maxTime: number; +} + +// ---------------------------------------------------------------------------\ +// Implementation +// ---------------------------------------------------------------------------\ + +/** + * Parse a base64-encoded Stellar transaction envelope XDR into a structured, + * human-readable object. + * + * @param xdrBase64 - Base64-encoded transaction envelope XDR string. + * @returns A parsed representation of the envelope. + * @throws If the XDR string cannot be decoded or is of an unknown type. + */ +export function parseEnvelope(xdrBase64: string): ParsedEnvelope { + const envelope = xdr.TransactionEnvelope.fromXDR(xdrBase64, "base64"); + + const parsed: ParsedEnvelope = { + sourceXdr: xdrBase64, + envelopeType: envelope.switch().name, + transaction: { + sourceAccount: "", + sequence: 0n, + fee: 0, + memo: null, + operations: [], + }, + signatures: [], + }; + + switch (envelope.switch().name) { + case "envelopeTypeTxV0": { + const v0 = envelope.v0(); + const tx: any = v0.tx(); + parsed.transaction = parseTxInner( + tx.sourceAccountEd25519(), + tx.seqNum(), + tx.fee(), + tx.memo(), + tx.operations(), + tx.timeBounds(), + ); + parsed.signatures = parseSignatures(v0.signatures()); + break; + } + case "envelopeTypeTx": { + const v1 = envelope.v1(); + const tx: any = v1.tx(); + parsed.transaction = parseTxInner( + tx.sourceAccount(), + tx.seqNum(), + tx.fee(), + tx.memo(), + tx.operations(), + tx.timeBounds(), + ); + parsed.signatures = parseSignatures(v1.signatures()); + break; + } + case "envelopeTypeTxFeeBump": { + const fb = envelope.feeBump(); + const innerTx: any = fb.tx().innerTx().v1().tx(); + parsed.transaction = parseTxInner( + innerTx.sourceAccount(), + innerTx.seqNum(), + innerTx.fee(), + innerTx.memo(), + innerTx.operations(), + innerTx.timeBounds(), + ); + parsed.signatures = parseSignatures(fb.signatures()); + parsed.envelopeType = "envelope_type_tx_fee_bump"; + break; + } + default: + throw new Error( + `Unsupported envelope type: ${envelope.switch().name}`, + ); + } + + return parsed; +} + +/** Parse the inner transaction fields common to all envelope variants. */ +function parseTxInner( + sourceAccount: any, + seqNum: any, + fee: any, + memo: any, + operations: any[], + timeBounds: any | null, +): ParsedTransaction { + let sourceAccountStr = ""; + try { + const ed25519 = typeof sourceAccount.ed25519 === "function" + ? sourceAccount.ed25519() + : sourceAccount; + sourceAccountStr = bytesToStrKey(ed25519); + } catch { + // ignore + } + + return { + sourceAccount: sourceAccountStr, + sequence: BigInt(String(seqNum)), + fee: Number(fee), + memo: parseMemo(memo), + operations: parseOps(operations), + timeBounds: timeBounds ? parseTimeBounds(timeBounds) : undefined, + }; +} + +/** Convert raw ed25519 bytes to a Stellar StrKey (base32). */ +function bytesToStrKey(raw: Buffer | Uint8Array): string { + try { + const buf = Buffer.isBuffer(raw) ? raw : Buffer.from(raw); + return StrKey.encodeEd25519PublicKey(buf); + } catch { + const buf = Buffer.isBuffer(raw) ? raw : Buffer.from(raw); + return `G${buf.toString("hex").substring(0, 54)}`; + } +} + +/** Parse memo from XDR. */ +function parseMemo(memo: any): ParsedMemo | null { + try { + const m = Memo.fromXDRObject(memo); + return { + type: m.type, + value: m.value?.toString() ?? null, + }; + } catch { + return { type: "unknown", value: null }; + } +} + +/** Parse an array of XDR operations. */ +function parseOps(ops: any[]): ParsedOperation[] { + return ops.map((op) => { + try { + const parsed = Operation.fromXDRObject(op) as unknown as Record; + return { + type: (parsed.type as string) ?? "unknown", + body: sanitiseBody(parsed), + source: parsed.source as string | undefined, + }; + } catch { + return { type: "unknown", body: {} }; + } + }); +} + +/** Strip circular / non-serialisable values from operation body. */ +function sanitiseBody(op: Record): Record { + const cleaned: Record = {}; + for (const key of Object.keys(op)) { + const val = op[key]; + if (val === null || val === undefined) continue; + if (typeof val === "bigint") { + cleaned[key] = val.toString(); + } else if (typeof val === "string" || typeof val === "number" || typeof val === "boolean") { + cleaned[key] = val; + } else if (Buffer.isBuffer(val)) { + cleaned[key] = (val as Buffer).toString("base64"); + } else if (Array.isArray(val)) { + cleaned[key] = val.map((v: unknown) => + typeof v === "string" || typeof v === "number" ? v : "[object]", + ); + } else { + cleaned[key] = "[object]"; + } + } + return cleaned; +} + +/** Parse signatures from XDR. */ +function parseSignatures( + sigs: { hint(): Buffer; signature(): Buffer }[], +): ParsedSignature[] { + return sigs.map((ds) => ({ + hint: Buffer.from(ds.hint()).toString("hex"), + signature: Buffer.from(ds.signature()).toString("hex"), + })); +} + +/** Parse time bounds. */ +function parseTimeBounds(tb: any): ParsedTimeBounds { + return { + minTime: Number(tb.minTime()), + maxTime: Number(tb.maxTime()), + }; +} From 28ce0267efae88ad9cb1d85b5d3314e6c205df87 Mon Sep 17 00:00:00 2001 From: Prasiejames Date: Tue, 28 Jul 2026 16:26:14 +0000 Subject: [PATCH 4/5] feat: add fee surge detector with Horizon fee-stats monitoring Adds src/feeSurgeDetector.ts to monitor real-time ledger fee statistics via Horizon and automatically recommend adjusted fee multipliers during network congestion. Uses Horizon Server.feeStats() for p10/p50/p95 percentiles. Integrates into txBuilder.ts for surge-adjusted fees before signing. Re-exported from feeEstimator.ts. --- src/feeSurgeDetector.ts | 208 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 src/feeSurgeDetector.ts diff --git a/src/feeSurgeDetector.ts b/src/feeSurgeDetector.ts new file mode 100644 index 0000000..75001ed --- /dev/null +++ b/src/feeSurgeDetector.ts @@ -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 { + 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))); +} From adad0bd4198021fd14279edf9bcf106f4f457b54 Mon Sep 17 00:00:00 2001 From: Prasiejames Date: Tue, 28 Jul 2026 16:26:24 +0000 Subject: [PATCH 5/5] chore: integrate all four new modules into SDK surface - Add new types (RecipientShare, SplitConfig, TrustlineEntry, TrustlineCheckResult, ParsedEnvelope, ParsedTransaction, ParsedOperation, ParsedMemo, ParsedSignature, ParsedTimeBounds, FeeSurgeConfig, FeeRecommendation, CongestionLevel) to types.ts - Wire split ratio validation into client.createInvoice() - Wire trustline check into client.validatePayment() - Add debug flag and parseXdrEnvelope() to client config - Add fee surge config option to StellarSplitClientConfig - Enable surge detection in StellarSplitTxBuilder.submit() - Export all new modules from index.ts - Re-export surge detector from feeEstimator.ts --- src/client.ts | 95 +++++++++++++++++++++++---- src/feeEstimator.ts | 6 ++ src/index.ts | 39 +++++++++++ src/txBuilder.ts | 26 +++++++- src/types.ts | 157 ++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 308 insertions(+), 15 deletions(-) diff --git a/src/client.ts b/src/client.ts index 8fe1921..3dac15e 100644 --- a/src/client.ts +++ b/src/client.ts @@ -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 { @@ -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. */ @@ -590,8 +595,6 @@ export function verifyCompletionProof(proof: CompletionProof): { } return { valid: true }; } -export class StellarSplitClient extends EventEmitter { - export class StellarSplitClient extends TypedEventEmitter { private _mainServer!: SorobanRpc.Server; private _standby: WarmStandby | null = null; @@ -1718,6 +1721,27 @@ export class StellarSplitClient extends TypedEventEmitter { 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 // --------------------------------------------------------------------------- @@ -1745,6 +1769,21 @@ export class StellarSplitClient extends TypedEventEmitter { 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( @@ -3352,7 +3391,35 @@ export class StellarSplitClient extends TypedEventEmitter { } } - 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 { diff --git a/src/feeEstimator.ts b/src/feeEstimator.ts index 35e6023..e104da9 100644 --- a/src/feeEstimator.ts +++ b/src/feeEstimator.ts @@ -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 { diff --git a/src/index.ts b/src/index.ts index 55a1aaa..24e6453 100644 --- a/src/index.ts +++ b/src/index.ts @@ -743,6 +743,45 @@ export type { HistoricalInvoiceSample, } from "./forecast.js"; +// --------------------------------------------------------------------------- +// Split ratio validator +// --------------------------------------------------------------------------- + +export { validateSplitRatios, validateSplitRatiosOrThrow, ratiosToRecipients } from "./validators/splitRatioValidator.js"; +export type { + RecipientShare, + SplitConfig, + SplitRatioValidationResult, +} from "./validators/splitRatioValidator.js"; + +// --------------------------------------------------------------------------- +// Trustline checker +// --------------------------------------------------------------------------- + +export { checkTrustlines, checkSingleTrustline } from "./trustlineChecker.js"; +export type { TrustlineEntry, TrustlineCheckResult } from "./trustlineChecker.js"; + +// --------------------------------------------------------------------------- +// XDR parser +// --------------------------------------------------------------------------- + +export { parseEnvelope } from "./xdrParser.js"; +export type { + ParsedEnvelope, + ParsedTransaction, + ParsedOperation, + ParsedMemo, + ParsedSignature, + ParsedTimeBounds, +} from "./xdrParser.js"; + +// --------------------------------------------------------------------------- +// Fee surge detector +// --------------------------------------------------------------------------- + +export { detectFeeSurge, clearFeeSurgeCache } from "./feeSurgeDetector.js"; +export type { FeeSurgeConfig, FeeRecommendation, CongestionLevel } from "./feeSurgeDetector.js"; + export { reconcileChannel, registerChannelStateFetcher, diff --git a/src/txBuilder.ts b/src/txBuilder.ts index d5a5db1..409f8ce 100644 --- a/src/txBuilder.ts +++ b/src/txBuilder.ts @@ -11,6 +11,8 @@ import { import type { StellarSplitClientConfig } from "./client.js"; import { signTransaction } from "./wallet.js"; import { SimulationFailedError, TransactionFailedError, TransactionNotConfirmedError } from "./errors.js"; +import { detectFeeSurge } from "./feeSurgeDetector.js"; +import type { FeeSurgeConfig } from "./feeSurgeDetector.js"; /** Builder for composing multi-operation StellarSplit transactions. */ export class StellarSplitTxBuilder { @@ -19,6 +21,7 @@ export class StellarSplitTxBuilder { private readonly config: StellarSplitClientConfig; private readonly sourceAddress: string; private readonly operations: xdr.Operation[] = []; + private _surgeConfig?: FeeSurgeConfig; constructor(config: StellarSplitClientConfig, sourceAddress: string) { this.config = config; @@ -28,6 +31,16 @@ export class StellarSplitTxBuilder { this.contract = new Contract(config.contractId); } + /** + * Enable surge-aware fee adjustment. When the network is congested the + * builder will apply an increased fee multiplier automatically before + * signing, keeping transactions from failing due to hard-coded fee values. + */ + enableSurgeDetection(config?: FeeSurgeConfig): this { + this._surgeConfig = config ?? {}; + return this; + } + addPay(invoiceId: string, amount: bigint | number | string): this { const op = this.contract.call( "pay", @@ -102,8 +115,19 @@ export class StellarSplitTxBuilder { async submit(): Promise<{ txHash: string }> { const account = await this.server.getAccount(this.sourceAddress); + // Resolve the fee: use surge-adjusted fee when enabled, otherwise BASE_FEE. + let fee = BASE_FEE; + if (this._surgeConfig && this.config.horizonUrl) { + try { + const rec = await detectFeeSurge(this.config.horizonUrl, this._surgeConfig); + fee = rec.surgeActive ? String(rec.fee) : BASE_FEE; + } catch { + // Surge detection failed — fall back to BASE_FEE silently. + } + } + const tb = new TransactionBuilder(account, { - fee: BASE_FEE, + fee, networkPassphrase: this.config.networkPassphrase, }); diff --git a/src/types.ts b/src/types.ts index 9e6d353..3dd3667 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1321,3 +1321,160 @@ export interface SignedBridgeProof { /** Source-chain address of the signer. */ signerAddress: string; } + +// --------------------------------------------------------------------------- +// Split Ratio Validator Types +// --------------------------------------------------------------------------- + +/** A single recipient share entry consumed by the ratio validator. */ +export interface RecipientShare { + /** Stellar address of the recipient. */ + address: string; + /** Share expressed as a decimal fraction (e.g. 0.4). Must sum to 1.0. */ + share: number; +} + +/** Split configuration consumed by the ratio validator. */ +export interface SplitConfig { + /** Ordered list of recipient shares. */ + shares: RecipientShare[]; + /** Floating-point comparison tolerance. Defaults to 1e-9. */ + tolerance?: number; +} + +/** Structured validation result from split ratio checks. */ +export interface SplitRatioValidationResult { + /** True when all checks pass. */ + valid: boolean; + /** Human-readable error messages. */ + errors: string[]; +} + +// --------------------------------------------------------------------------- +// Trustline Checker Types +// --------------------------------------------------------------------------- + +/** Result for a single recipient's trustline check. */ +export interface TrustlineEntry { + /** Stellar address of the recipient. */ + address: string; + /** Whether the recipient has a trustline for the required token. */ + hasTrustline: boolean; + /** The asset code (e.g. "USDC") the trustline must cover, if applicable. */ + assetCode?: string; + /** The asset issuer / contract address for which the trustline must exist. */ + assetIssuer?: string; +} + +/** Full trustline check report. */ +export interface TrustlineCheckResult { + /** True when every recipient has the required trustline. */ + allReady: boolean; + /** Per-recipient results. */ + entries: TrustlineEntry[]; +} + +// --------------------------------------------------------------------------- +// XDR Parser Types +// --------------------------------------------------------------------------- + +/** Decoded representation of a single operation within a transaction. */ +export interface ParsedOperation { + /** Human-readable operation type (e.g. "payment"). */ + type: string; + /** The raw operation body as a JSON-serialisable object. */ + body: Record; + /** Optional source account override for this operation. */ + source?: string; +} + +/** Structured representation of a decoded transaction envelope. */ +export interface ParsedEnvelope { + /** Base64-encoded source XDR that was parsed. */ + sourceXdr: string; + /** Envelope type name. */ + envelopeType: string; + /** Parsed transaction body. */ + transaction: ParsedTransaction; + /** List of signatures attached to the envelope. */ + signatures: ParsedSignature[]; +} + +/** Parsed representation of the inner transaction. */ +export interface ParsedTransaction { + /** Source account public key (G...). */ + sourceAccount: string; + /** Sequence number. */ + sequence: bigint; + /** Fee in stroops. */ + fee: number; + /** Parsed memo, if present. */ + memo: ParsedMemo | null; + /** Ordered list of operations. */ + operations: ParsedOperation[]; + /** Time bounds, if set. */ + timeBounds?: ParsedTimeBounds; +} + +/** Parsed memo representation. */ +export interface ParsedMemo { + /** Memo type (e.g. "id", "text", "hash", "return", "none"). */ + type: string; + /** Memo value, stringified for readability. */ + value: string | null; +} + +/** Parsed signature. */ +export interface ParsedSignature { + /** Hex-encoded signature hint (last 4 bytes of the public key). */ + hint: string; + /** Hex-encoded signature bytes. */ + signature: string; +} + +/** Decoded time bounds. */ +export interface ParsedTimeBounds { + /** Minimum time bound (Unix timestamp), or 0 if no lower bound. */ + minTime: number; + /** Maximum time bound (Unix timestamp), or 0 if no upper bound. */ + maxTime: number; +} + +// --------------------------------------------------------------------------- +// Fee Surge Detector Types +// --------------------------------------------------------------------------- + +/** Configuration for the fee surge detector. */ +export interface FeeSurgeConfig { + /** Fee percentile to track. Defaults to "p50". */ + percentile?: "p10" | "p50" | "p95"; + /** Congestion threshold multiplier. Defaults to 2. */ + surgeMultiplier?: number; + /** Recommended fee multiplier applied during surge. Defaults to 1.5. */ + 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. Defaults to 10_000_000. */ + 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; +}