diff --git a/src/tests/offerPriceValidation.test.ts b/src/tests/offerPriceValidation.test.ts new file mode 100644 index 0000000..8dfc8f4 --- /dev/null +++ b/src/tests/offerPriceValidation.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import type { ResolvedNetworkConfig } from "../shared/types"; +import { compose } from "../transaction/compose"; +import { + DEFAULT_MAX_OFFER_PRICE, + DEFAULT_MIN_OFFER_PRICE, +} from "../transaction/offerPrice"; + +const { mockLoadAccount } = vi.hoisted(() => ({ + mockLoadAccount: vi.fn(), +})); + +const MOCK_XDR = "AAAAAQAAAAA="; + +vi.mock("@stellar/stellar-sdk", async (importOriginal) => { + const actual = await importOriginal(); + + class MockTransactionBuilder { + constructor(_sourceAccount: unknown, _options: unknown) {} + + addOperation() { + return this; + } + + setTimeout() { + return this; + } + + addMemo() { + return this; + } + + build() { + return { toXDR: () => MOCK_XDR }; + } + } + + return { + ...actual, + Horizon: { + ...actual.Horizon, + Server: vi.fn().mockImplementation(() => ({ + loadAccount: mockLoadAccount, + })), + }, + TransactionBuilder: MockTransactionBuilder, + }; +}); + +import { buildManageOfferTransaction } from "../transaction/buildTransaction"; + +const networkConfig: ResolvedNetworkConfig = { + network: "testnet", + horizonUrl: "https://horizon-testnet.stellar.org", + rpcUrl: "https://soroban-testnet.stellar.org", + networkPassphrase: "Test SDF Network ; September 2015", +}; + +const TRADER = "GBTABBLFJWSIJKGRVJMOV477L42GXCHFHGDUOCDMC7MXWASTPZKQNB25"; +const ISSUER = "GDHU6WRG4IEQXM5NZ4BMPKOXHW76MZM4Y2IEMFDVXBSDP6SJY4ITNPP2"; + +const mockAccount = { + accountId: () => TRADER, + sequenceNumber: () => "1", + incrementSequenceNumber: () => {}, +}; + +const baseOffer = { + sellingAssetCode: "XLM", + buyingAssetCode: "EURC", + buyingAssetIssuer: ISSUER, + amount: "100", +}; + +describe("offer price ratio validation", () => { + let warning: ReturnType; + + beforeEach(() => { + mockLoadAccount.mockResolvedValue(mockAccount); + warning = vi.spyOn(console, "warn").mockImplementation(() => undefined); + }); + + afterEach(() => { + warning.mockRestore(); + }); + + it("warns for prices below the default minimum", async () => { + const result = await buildManageOfferTransaction( + networkConfig.horizonUrl, + networkConfig, + TRADER, + { ...baseOffer, price: "0.0001" }, + ); + + expect(result.status).toBe("ok"); + expect(warning).toHaveBeenCalledWith( + expect.stringContaining(`[${DEFAULT_MIN_OFFER_PRICE}`), + ); + }); + + it("warns for prices above the default maximum, including rational prices", async () => { + const result = await buildManageOfferTransaction( + networkConfig.horizonUrl, + networkConfig, + TRADER, + { ...baseOffer, price: { n: 1_000_000_000, d: 1 } as unknown as string }, + ); + + expect(result.status).toBe("ok"); + expect(warning).toHaveBeenCalledWith( + expect.stringContaining(`${DEFAULT_MAX_OFFER_PRICE}]`), + ); + }); + + it("allows callers to suppress the warning", async () => { + const result = await buildManageOfferTransaction( + networkConfig.horizonUrl, + networkConfig, + TRADER, + { ...baseOffer, price: "0.0001", suppressPriceWarnings: true }, + ); + + expect(result.status).toBe("ok"); + expect(warning).not.toHaveBeenCalled(); + }); + + it("does not warn at the inclusive default boundaries", async () => { + const composeBuilder = compose(TRADER, networkConfig, { + sequenceNumber: "1", + estimatedFee: "100", + }); + + composeBuilder.addManageOffer({ + selling: { assetCode: "XLM" }, + buying: { assetCode: "EURC", assetIssuer: ISSUER }, + amount: "100", + price: String(DEFAULT_MIN_OFFER_PRICE), + }); + composeBuilder.addManageOffer({ + selling: { assetCode: "XLM" }, + buying: { assetCode: "EURC", assetIssuer: ISSUER }, + amount: "100", + price: String(DEFAULT_MAX_OFFER_PRICE), + }); + + expect(warning).not.toHaveBeenCalled(); + }); + + it("warns from the compose builder and honors builder-level suppression", () => { + compose(TRADER, networkConfig, { suppressPriceWarnings: true }).addManageOffer({ + selling: { assetCode: "XLM" }, + buying: { assetCode: "EURC", assetIssuer: ISSUER }, + amount: "100", + price: "0.00001", + }); + + expect(warning).not.toHaveBeenCalled(); + + compose(TRADER, networkConfig).addManageBuyOffer({ + selling: { assetCode: "XLM" }, + buying: { assetCode: "EURC", assetIssuer: ISSUER }, + amount: "100", + price: "1001", + }); + + expect(warning).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/tests/pathPaymentPathValidation.test.ts b/src/tests/pathPaymentPathValidation.test.ts new file mode 100644 index 0000000..4d437fd --- /dev/null +++ b/src/tests/pathPaymentPathValidation.test.ts @@ -0,0 +1,98 @@ +import { Keypair, Networks } from "@stellar/stellar-sdk"; +import { describe, expect, it } from "vitest"; +import { SorokitErrorCode } from "../shared/response"; +import { buildPathPayment } from "../transaction/buildTransaction"; +import { + buildPathPaymentTransaction, + MAX_PATH_PAYMENT_INTERMEDIATE_ASSETS, + validatePathPaymentPathLength, +} from "../transaction/pathPayment"; + +const issuer = Keypair.random().publicKey(); +const source = Keypair.random().publicKey(); +const destination = Keypair.random().publicKey(); +const network = { + horizonUrl: "https://example.invalid", + networkPassphrase: Networks.TESTNET, + sorobanRpcUrl: "https://example.invalid", +}; + +const baseParams = { + destination, + sendAssetCode: "XLM", + destAssetCode: "USDC", + destAssetIssuer: issuer, + amount: "100", + mode: "strict-send" as const, + sequenceNumber: "1", + estimatedFee: "100", + slippageAmount: "95", +}; + +function intermediateAssets(count: number) { + return Array.from({ length: count }, () => ({ + assetCode: "USDC", + assetIssuer: issuer, + })); +} + +describe("path payment path length validation", () => { + it("requires at least one intermediate asset when a path is provided", async () => { + const result = await buildPathPayment(network.horizonUrl, network, source, { + ...baseParams, + path: [], + }); + + expect(result.status).toBe("error"); + if (result.status === "error") { + expect(result.error.code).toBe(SorokitErrorCode.ROUTER_INVALID_PATH); + expect(result.error.message).toContain("at least one intermediate asset"); + } + }); + + it("rejects paths longer than the Stellar maximum", async () => { + const result = await buildPathPayment(network.horizonUrl, network, source, { + ...baseParams, + path: intermediateAssets(MAX_PATH_PAYMENT_INTERMEDIATE_ASSETS + 1), + }); + + expect(result.status).toBe("error"); + if (result.status === "error") { + expect(result.error.code).toBe(SorokitErrorCode.ROUTER_INVALID_PATH); + expect(result.error.message).toContain( + `more than ${MAX_PATH_PAYMENT_INTERMEDIATE_ASSETS}`, + ); + } + }); + + it("validates path length in buildPathPaymentTransaction", async () => { + const result = await buildPathPaymentTransaction({ + sourcePublicKey: source, + destination, + sendAssetCode: "XLM", + sendAssetIssuer: null, + destAssetCode: "USDC", + destAssetIssuer: issuer, + amount: "100", + mode: "strict-send", + }); + + expect(result.status).toBe("error"); + if (result.status === "error") { + expect(result.error.code).toBe(SorokitErrorCode.ROUTER_INVALID_PATH); + expect(result.error.message).toContain("at least one intermediate asset"); + } + }); + + it("allows empty paths only when the caller opts into direct conversions", () => { + expect(validatePathPaymentPathLength(0).status).toBe("error"); + expect(validatePathPaymentPathLength(0, { allowEmpty: true }).status).toBe( + "ok", + ); + expect(validatePathPaymentPathLength(1).status).toBe("ok"); + expect( + validatePathPaymentPathLength(MAX_PATH_PAYMENT_INTERMEDIATE_ASSETS) + .status, + ).toBe("ok"); + }); +}); diff --git a/src/transaction/buildTransaction.ts b/src/transaction/buildTransaction.ts index 9df22cf..4fd17be 100644 --- a/src/transaction/buildTransaction.ts +++ b/src/transaction/buildTransaction.ts @@ -34,6 +34,8 @@ import { DEFAULT_TX_TIMEOUT_SECONDS } from "../shared/constants"; import type { ResolvedNetworkConfig } from "../shared/types"; import { createHorizonServer, createSorobanServer } from "../shared/serverFactory"; import { MAX_OPERATIONS_PER_TRANSACTION } from "./validateTransaction"; +import { validatePathPaymentPathLength } from "./pathPayment"; +import { warnOnExtremeOfferPrice } from "./offerPrice"; import type { MemoParams, MemoValidationConfig, @@ -927,6 +929,12 @@ function resolvePathAssets( * Build a path payment transaction XDR. * Use mode "strict-send" to send an exact amount, or "strict-receive" to receive an exact amount. * Returns the unsigned XDR ready for signing. + * + * Path semantics: + * - Omit `path` to let Horizon discover a route, including a direct route with + * no intermediate assets (a valid Stellar direct conversion). + * - If `path` is provided it must contain 1–5 intermediate assets. An explicit + * empty array is rejected as ambiguous; omit it instead. */ export async function buildPathPayment( horizonUrl: string, @@ -948,6 +956,14 @@ export async function buildPathPayment( console.warn(message); } } + + if (params.path !== undefined) { + const explicitPathLength = validatePathPaymentPathLength( + params.path.length, + ); + if (explicitPathLength.status === "error") return explicitPathLength; + } + const sendAssetResult = resolveAsset( params.sendAssetCode, params.sendAssetIssuer, @@ -1060,6 +1076,12 @@ export async function buildPathPayment( } } + const finalPathLength = validatePathPaymentPathLength( + finalPath?.length ?? 0, + { allowEmpty: true }, + ); + if (finalPathLength.status === "error") return finalPathLength; + const pathResult = resolvePathAssets(finalPath); if (pathResult.status === "error") return pathResult; @@ -1971,6 +1993,16 @@ export async function buildManageOfferTransaction( const priceResult = validateOfferPrice(params.price); if (priceResult.status === "error") return priceResult; + if (parseFloat(params.amount) > 0) { + warnOnExtremeOfferPrice(params.price, { + suppress: params.suppressPriceWarnings === true, + ...(params.priceWarningRange !== undefined && { + range: params.priceWarningRange, + }), + context: `Offer price (${params.sellingAssetCode}/${params.buyingAssetCode})`, + }); + } + // ── Memo ────────────────────────────────────────────────────────────────── const memoResult = validateMemoParams(params); if (memoResult.status === "error") return memoResult; diff --git a/src/transaction/compose.ts b/src/transaction/compose.ts index 78b25ed..e4fe0a1 100644 --- a/src/transaction/compose.ts +++ b/src/transaction/compose.ts @@ -53,6 +53,7 @@ import { buildDataEntryOperation } from "../account/dataEntries"; import type { DataEntryValue } from "./dataEntry"; import type { MemoType, SetOptionsParams } from "./types"; import type { ClaimPredicateInput } from "./types"; +import { warnOnExtremeOfferPrice } from "./offerPrice"; export type ComposeNetwork = ResolvedNetworkConfig | NetworkType; @@ -94,6 +95,10 @@ export interface ComposeOptions { memoize?: boolean; /** Disable the mutable-operation warning for deliberate expert reuse. */ warnOnMutableMemoized?: boolean; + /** Suppress extreme offer-price warnings for this builder. */ + suppressPriceWarnings?: boolean; + /** Override the default offer-price warning bounds (0.001–1000). */ + priceWarningRange?: { min?: number; max?: number }; } export interface ComposePaymentParams { @@ -120,8 +125,15 @@ export interface ComposeOfferParams { /** * Price of 1 unit of `selling` in terms of `buying`: a decimal string * (e.g. `"1.5"`) or a rational `{ n, d }`. + * + * Prices below 0.001 or above 1000 trigger an advisory warning unless + * `suppressPriceWarnings` is true. */ price: string | { n: number; d: number }; + /** Suppress the extreme-price warning for this operation. */ + suppressPriceWarnings?: boolean; + /** Override the default offer-price warning bounds (0.001–1000). */ + priceWarningRange?: { min?: number; max?: number }; } export interface ComposePathPaymentBase { @@ -328,6 +340,17 @@ export function compose( } }; + const warnOnOfferPrice = (params: ComposeOfferParams): void => { + if (!(Number(params.amount) > 0)) return; + const range = params.priceWarningRange ?? options.priceWarningRange; + warnOnExtremeOfferPrice(params.price, { + suppress: + params.suppressPriceWarnings === true || + options.suppressPriceWarnings === true, + ...(range !== undefined && { range }), + }); + }; + const push = (op: PendingComposeOperation): void => { pendingOps.push(op); }; @@ -452,6 +475,7 @@ export function compose( }); return builder; } + warnOnOfferPrice(params); push({ kind: "manageOffer", build: () => { @@ -497,6 +521,7 @@ export function compose( }); return builder; } + warnOnOfferPrice(params); push({ kind: "manageBuyOffer", build: () => { diff --git a/src/transaction/estimateFee.ts b/src/transaction/estimateFee.ts index 003cf12..82c7e03 100644 --- a/src/transaction/estimateFee.ts +++ b/src/transaction/estimateFee.ts @@ -161,6 +161,220 @@ export interface FeeEstimate { congestion?: CongestionFeeEstimate; /** The priority level applied to this estimate. Undefined when no priority was requested. */ priority?: TransactionPriority; + /** Complexity factors used to adjust the simulated fee. */ + complexity?: TransactionComplexity; +} + +/** + * The complexity factors considered when estimating a fee (issue #850). + * + * Stellar's inclusion fee is charged per operation, while Soroban simulation + * captures contract resource usage. Neither fully describes a transaction's + * footprint, so `estimateFee` also accounts for the factors below and applies a + * bounded premium to the simulated fee. + */ +export interface TransactionComplexity { + /** Number of operations in the transaction (at least 1). */ + operationCount: number; + /** Stellar operation types present in the transaction. */ + operationTypes: string[]; + /** Number of signatures required/attached, including multisig signers. */ + signerCount: number; + /** Operations that create, modify, or consume a trustline. */ + trustlineOperationCount: number; + /** Whether the transaction carries a memo. */ + hasMemo: boolean; + /** Bounded multiplier applied to the simulated fee (1 = no premium). */ + multiplier: number; + /** Human-readable list of the factors that contributed to the premium. */ + factors: string[]; +} + +/** + * Maximum complexity premium. Keeping the premium at or below 15% ensures + * estimates stay within ±20% of the fee actually charged for the same + * transaction, even when simulation already accounts for resource usage. + */ +export const MAX_COMPLEXITY_MULTIPLIER = 1.15; + +/** + * Per-operation complexity weights. Cheap, payment-like operations carry no + * premium so a simple one-operation transaction keeps the historical estimate. + * Operations that allocate or mutate ledger state carry a premium. + */ +const OPERATION_COMPLEXITY_WEIGHTS: Readonly> = { + createAccount: 0, + payment: 0, + bumpSequence: 0, + manageData: 0.01, + setOptions: 0.03, + changeTrust: 0.06, + allowTrust: 0.04, + setTrustLineFlags: 0.04, + pathPaymentStrictSend: 0.05, + pathPaymentStrictReceive: 0.05, + manageSellOffer: 0.04, + manageBuyOffer: 0.04, + createPassiveSellOffer: 0.03, + createClaimableBalance: 0.04, + claimClaimableBalance: 0.03, + beginSponsoringFutureReserves: 0.04, + endSponsoringFutureReserves: 0.04, + revokeSponsorship: 0.04, + clawback: 0.03, + clawbackClaimableBalance: 0.03, + liquidityPoolDeposit: 0.05, + liquidityPoolWithdraw: 0.05, + invokeHostFunction: 0.06, + extendFootprintTtl: 0.04, + restoreFootprint: 0.04, +}; + +/** Fallback premium for operation types not listed in the weight table. */ +const DEFAULT_OPERATION_COMPLEXITY_WEIGHT = 0.02; + +/** Operations that create, modify, or consume a trustline. */ +const TRUSTLINE_OPERATION_TYPES: ReadonlySet = new Set([ + "changeTrust", + "allowTrust", + "setTrustLineFlags", + "pathPaymentStrictSend", + "pathPaymentStrictReceive", + "manageSellOffer", + "manageBuyOffer", + "createPassiveSellOffer", + "liquidityPoolDeposit", + "liquidityPoolWithdraw", +]); + +/** Extra premium per operation beyond the first. */ +const COMPLEXITY_PER_EXTRA_OPERATION = 0.02; + +/** Extra premium per signer beyond the first (multisig authorisation). */ +const COMPLEXITY_PER_EXTRA_SIGNER = 0.03; + +/** Small premium for the extra envelope bytes of a memo. */ +const COMPLEXITY_PER_MEMO = 0.005; + +interface TransactionLike { + operations?: unknown; + signatures?: unknown; + innerTransaction?: unknown; + memo?: unknown; +} + +function asTransactionLike(value: unknown): TransactionLike | undefined { + return value !== null && typeof value === "object" ? (value as TransactionLike) : undefined; +} + +/** Unwrap fee-bump transactions so the inner transaction's details are used. */ +function unwrapTransaction(value: unknown): TransactionLike | undefined { + const transaction = asTransactionLike(value); + if (!transaction) return undefined; + const inner = asTransactionLike(transaction.innerTransaction); + return inner ?? transaction; +} + +function operationTypeOf(operation: unknown): string { + if (operation !== null && typeof operation === "object") { + const type = (operation as { type?: unknown }).type; + if (typeof type === "string" && type.length > 0) return type; + } + return "unknown"; +} + +function signatureCountOf(transaction: TransactionLike | undefined): number { + if (!transaction) return 0; + const outer = Array.isArray(transaction.signatures) ? transaction.signatures.length : 0; + const inner = asTransactionLike(transaction.innerTransaction); + const innerCount = inner && Array.isArray(inner.signatures) ? inner.signatures.length : 0; + return Math.max(outer, innerCount); +} + +function hasMemoOf(transaction: TransactionLike | undefined): boolean { + const memo = asTransactionLike(transaction?.memo); + if (!memo) return false; + const type = (memo as { type?: unknown }).type; + return typeof type === "string" && type !== "none" && type !== "MEMO_NONE"; +} + +/** + * Derive the bounded complexity premium for a transaction. + * + * Factors included: + * - operation count beyond the first (inclusion fee scales per operation); + * - state-mutating operation types (trustlines, offers, path payments, + * contract invocations, sponsorship, liquidity pools, etc.); + * - trustline-touching operations; + * - multisig signer count; + * - memo payload. + * + * Soroban CPU/memory/ledger resource usage is already reflected in the + * simulation's `minResourceFee`, so it is not double-counted here; the + * operation-count floor is applied separately by `estimateFee`. + */ +function analyzeTransactionComplexity( + transaction: unknown, + requestedSignerCount?: number, +): TransactionComplexity { + const tx = unwrapTransaction(transaction); + const operations = Array.isArray(tx?.operations) ? (tx.operations as unknown[]) : []; + const operationTypes = operations.map(operationTypeOf); + const operationCount = Math.max(1, operations.length); + const trustlineOperationCount = operationTypes.filter((type) => + TRUSTLINE_OPERATION_TYPES.has(type), + ).length; + const attachedSigners = signatureCountOf(tx); + const requestedSigners = + Number.isFinite(requestedSignerCount) && (requestedSignerCount ?? 0) > 0 + ? Math.floor(requestedSignerCount as number) + : 0; + const signerCount = Math.max(1, requestedSigners, attachedSigners); + const hasMemo = hasMemoOf(tx); + const factors: string[] = []; + let score = 0; + + if (operationCount > 1) { + score += COMPLEXITY_PER_EXTRA_OPERATION * (operationCount - 1); + factors.push(`${operationCount} operations`); + } + + const seenTypes = new Set(); + for (const type of operationTypes) { + const weight = OPERATION_COMPLEXITY_WEIGHTS[type] ?? DEFAULT_OPERATION_COMPLEXITY_WEIGHT; + if (weight > 0) { + score += weight; + if (!seenTypes.has(type)) { + factors.push(`${type} operation`); + seenTypes.add(type); + } + } + } + + if (trustlineOperationCount > 0) { + score += COMPLEXITY_PER_EXTRA_OPERATION * trustlineOperationCount; + factors.push(`${trustlineOperationCount} trustline-touching operation(s)`); + } + + if (signerCount > 1) { + score += COMPLEXITY_PER_EXTRA_SIGNER * (signerCount - 1); + factors.push(`${signerCount} signers`); + } + + if (hasMemo) { + score += COMPLEXITY_PER_MEMO; + factors.push("memo present"); + } + + return { + operationCount, + operationTypes, + signerCount, + trustlineOperationCount, + hasMemo, + multiplier: Math.min(MAX_COMPLEXITY_MULTIPLIER, 1 + score), + factors, + }; } /** @@ -249,6 +463,12 @@ export function calculateAdaptiveFee( export interface FeeEstimateOptions { /** Client-level cache for storing the recent median fee */ cache?: SorokitCache; + /** + * Number of signatures that will authorise the transaction (multisig). + * When omitted, the signature count present in the XDR is used; unsigned + * transactions default to a single signer. + */ + signerCount?: number; /** Invoked when a fee surge is detected — useful for logging or UI alerts */ onFeeSurge?: (estimate: FeeEstimate) => void; /** When true, fetches recent transaction fees from Horizon and adds tier recommendations */ @@ -503,6 +723,20 @@ function describeFeeEstimateFailure(cause: unknown): string { * transactions (via Horizon). When the fee exceeds 2× that median, `surge: true` * is set on the result and `onFeeSurge` is invoked if provided. * + * Estimates are complexity-aware (issue #850). The simulated fee is raised to + * the inclusion-fee floor for the operation count (network base fee × number of + * operations), then a bounded premium (at most 15%) is applied for: + * - operations beyond the first; + * - state-mutating operation types (trustlines, offers, path payments, + * contract invocations, sponsorship, liquidity pools, etc.); + * - trustline-touching operations; + * - multisig signer count (pass `signerCount` when signing an unsigned XDR); + * - memo payloads. + * + * The factors and the applied multiplier are returned in + * `FeeEstimate.complexity`. The premium is capped so estimates stay within + * ±20% of the fee actually charged. + * * @param rpcUrl - Base URL of the Soroban RPC server. * @param horizonUrl - Base URL of the Horizon server (used in payment mode). * @param networkConfig - Resolved network configuration. @@ -598,7 +832,11 @@ export async function estimateFee( // For "xdr" input this happens before any network call; // for "payment" input this happens after the Horizon account fetch but // before the more expensive Soroban simulation. - const cacheKey = `sorokit:fee:${createHash("sha256").update(xdr).digest("hex")}`; + const signerCacheKey = + Number.isFinite(options?.signerCount) && (options?.signerCount ?? 0) > 0 + ? `:signers:${Math.floor(options?.signerCount as number)}` + : ""; + const cacheKey = `sorokit:fee:${createHash("sha256").update(xdr).digest("hex")}${signerCacheKey}`; if (cache) { const cached = cache.get(cacheKey); if (cached != null) return ok(cached as FeeEstimate); @@ -630,6 +868,22 @@ export async function estimateFee( simulated = false; } + // Complexity-aware adjustment (issue #850). The inclusion fee is charged + // per operation, so first raise the estimate to the operation-count floor. + // Then apply a bounded premium for factors that simulation may not expose + // (extra operations, state-mutating operation types, trustlines, multisig + // signers, and memos). The premium is only applied to simulated results; + // the fallback path uses the deterministic inclusion floor directly. + const complexity = analyzeTransactionComplexity(tx, options?.signerCount); + const inclusionFeeFloor = networkBaseFee * complexity.operationCount; + feeStroops = Math.max(inclusionFeeFloor, feeStroops); + if (simulated && complexity.multiplier > 1) { + feeStroops = Math.max( + inclusionFeeFloor, + Math.round(feeStroops * complexity.multiplier), + ); + } + // Apply urgency and bounded network-trend adjustment only when requested; // callers that omit these options retain the historical estimate exactly. const priority = options?.priority ?? options?.urgency; @@ -638,13 +892,13 @@ export async function estimateFee( const urgencyFee = calculateAdaptiveFee(feeStroops, { ...(priority ? { urgency: priority } : {}), feeHistory: options?.feeHistory ?? getFeeHistory(networkConfig.networkPassphrase), - baseFeeFloor: networkBaseFee, + baseFeeFloor: inclusionFeeFloor, ...(options?.minMultiplier !== undefined ? { minMultiplier: options.minMultiplier } : {}), ...(options?.maxMultiplier !== undefined ? { maxMultiplier: options.maxMultiplier } : {}), }); if (priority && options?.priorityMultipliers) { const customMultiplier = multipliers[priority]; - feeStroops = Math.max(networkBaseFee, Math.round(feeStroops * customMultiplier)); + feeStroops = Math.max(inclusionFeeFloor, Math.round(feeStroops * customMultiplier)); } else { feeStroops = urgencyFee; } @@ -657,6 +911,7 @@ export async function estimateFee( feeXlm, baseFee: String(networkBaseFee), simulated, + complexity, ...(priority ? { priority } : {}), }; diff --git a/src/transaction/offerPrice.ts b/src/transaction/offerPrice.ts new file mode 100644 index 0000000..e196edd --- /dev/null +++ b/src/transaction/offerPrice.ts @@ -0,0 +1,105 @@ +/** + * Price-ratio sanity checks for DEX offers. + * + * Stellar offer prices are expressed as "units of buying asset per unit of + * selling asset". A price that is accidentally inverted or off by orders of + * magnitude can still produce a valid transaction, but the offer will either + * never fill or fill at a disastrous rate. These helpers warn before that + * happens without blocking advanced use cases. + */ + +/** Offer price as a decimal string or a rational numerator/denominator pair. */ +export type OfferPrice = string | { n: number; d: number }; + +/** Inclusive warning bounds for an offer price ratio. */ +export interface OfferPriceWarningRange { + /** Lower bound. Values below this warn. Defaults to 0.001. */ + min?: number; + /** Upper bound. Values above this warn. Defaults to 1000. */ + max?: number; +} + +/** Options for {@link warnOnExtremeOfferPrice}. */ +export interface OfferPriceWarningOptions { + /** Suppress the warning for intentionally extreme prices. */ + suppress?: boolean; + /** Override the default warning bounds. */ + range?: OfferPriceWarningRange; + /** Label included in the warning, e.g. "Offer price (XLM/USDC)". */ + context?: string; +} + +/** Minimum typical offer price. Prices below this trigger a warning. */ +export const DEFAULT_MIN_OFFER_PRICE = 0.001; + +/** Maximum typical offer price. Prices above this trigger a warning. */ +export const DEFAULT_MAX_OFFER_PRICE = 1000; + +/** + * Convert an offer price to its numeric ratio. + * + * Returns `null` for malformed, non-positive, or non-finite prices. Rational + * prices must have a positive numerator and denominator. + */ +export function getOfferPriceRatio(price: OfferPrice): number | null { + if (typeof price === "string") { + const value = Number(price.trim()); + return Number.isFinite(value) && value > 0 ? value : null; + } + + if ( + typeof price !== "object" || + price === null || + !Number.isFinite(price.n) || + !Number.isFinite(price.d) || + price.n <= 0 || + price.d <= 0 + ) { + return null; + } + + const value = price.n / price.d; + return Number.isFinite(value) && value > 0 ? value : null; +} + +/** + * Warn when an offer price falls outside the typical range. + * + * Defaults to warning below 0.001 or above 1000. The warning is advisory: it + * never throws or rejects the offer. Set `suppress` to true (or expose the + * corresponding `suppressPriceWarnings` parameter) for deliberate extreme + * prices. Returns the parsed ratio, or `null` when the price is malformed. + */ +export function warnOnExtremeOfferPrice( + price: OfferPrice, + options?: OfferPriceWarningOptions, +): number | null { + const ratio = getOfferPriceRatio(price); + if (ratio === null || options?.suppress === true) return ratio; + + const min = options?.range?.min ?? DEFAULT_MIN_OFFER_PRICE; + const max = options?.range?.max ?? DEFAULT_MAX_OFFER_PRICE; + if ( + !Number.isFinite(min) || + !Number.isFinite(max) || + min < 0 || + max <= 0 || + min > max + ) { + return ratio; + } + + if (ratio >= min && ratio <= max) return ratio; + + const label = + typeof price === "string" ? price : `${price.n}/${price.d}`; + const context = options?.context ?? "Offer price"; + console.warn( + `[sorokit] ${context} ${label} is outside the typical range ` + + `[${min}, ${max}]. Double-check that the selling and buying assets are ` + + "not inverted and that the price scale is intentional. Set " + + "suppressPriceWarnings: true to disable this warning.", + ); + + return ratio; +} diff --git a/src/transaction/pathPayment.ts b/src/transaction/pathPayment.ts index 908eb4f..4ccea77 100644 --- a/src/transaction/pathPayment.ts +++ b/src/transaction/pathPayment.ts @@ -70,7 +70,15 @@ export interface BuildPathPaymentParams { amount: string; /** Mode: strict-send or strict-receive */ mode: "strict-send" | "strict-receive"; - /** Optional pre-computed path */ + /** + * Pre-computed intermediate path. + * + * When provided, it must contain between 1 and 5 intermediate assets. An + * explicit empty array is rejected as ambiguous: omit `path` to let the + * builder auto-discover a route, or use a regular payment for a direct + * same-asset transfer. A path discovered as empty is a valid direct + * conversion under the Stellar protocol. + */ path?: SwapRouteAsset[]; /** How to handle a destination equal to sourcePublicKey. Defaults to "warn". */ selfPaymentBehavior?: "warn" | "error" | "allow"; @@ -78,6 +86,59 @@ export interface BuildPathPaymentParams { allowSelfPayment?: boolean; } +/** + * Maximum number of intermediate assets a Stellar path payment can contain. + * @see https://developers.stellar.org/docs/learn/fundamentals/operations/overview + */ +export const MAX_PATH_PAYMENT_INTERMEDIATE_ASSETS = 5; + +/** Options for {@link validatePathPaymentPathLength}. */ +export interface PathPaymentPathLengthOptions { + /** Allow a zero-length path for a direct conversion. Defaults to false. */ + allowEmpty?: boolean; + /** Field name used in error messages. Defaults to "path". */ + fieldName?: string; +} + +/** + * Validate the number of intermediate hops in a path payment. + * + * Stellar allows direct conversions with an empty path, but an explicit empty + * path is ambiguous for callers of this module. By default the path must + * contain at least one intermediate asset and no more than + * {@link MAX_PATH_PAYMENT_INTERMEDIATE_ASSETS}. Pass `allowEmpty: true` for + * paths returned by Horizon, where an empty path means a direct conversion. + */ +export function validatePathPaymentPathLength( + pathLength: number, + options?: PathPaymentPathLengthOptions, +): SorokitResult { + const fieldName = options?.fieldName ?? "path"; + + if (!Number.isInteger(pathLength) || pathLength < 0) { + return err( + SorokitErrorCode.ROUTER_INVALID_PATH, + `${fieldName} length must be a non-negative integer`, + ); + } + + if (pathLength === 0 && options?.allowEmpty !== true) { + return err( + SorokitErrorCode.ROUTER_INVALID_PATH, + `${fieldName} must contain at least one intermediate asset; omit it to auto-discover a route`, + ); + } + + if (pathLength > MAX_PATH_PAYMENT_INTERMEDIATE_ASSETS) { + return err( + SorokitErrorCode.ROUTER_INVALID_PATH, + `${fieldName} cannot contain more than ${MAX_PATH_PAYMENT_INTERMEDIATE_ASSETS} intermediate assets`, + ); + } + + return ok(pathLength); +} + /** * Convert a router failure into a stable, descriptive Sorokit error code. * @@ -154,7 +215,12 @@ export async function findSwapPath( /** * Build a path payment transaction XDR. - * + * + * `params.path` must contain between 1 and 5 intermediate assets. Empty paths + * are rejected here because this builder does not auto-discover routes; use + * `buildPathPayment` to auto-discover, or a regular payment for a direct + * same-asset transfer. + * * @param params - Path payment parameters * @returns Transaction XDR or error */ @@ -173,6 +239,12 @@ export async function buildPathPaymentTransaction( console.warn(message); } } + + const pathLengthResult = validatePathPaymentPathLength( + params.path?.length ?? 0, + ); + if (pathLengthResult.status === "error") return pathLengthResult; + // Placeholder implementation // In production, this would use Stellar SDK to build the transaction diff --git a/src/transaction/types.ts b/src/transaction/types.ts index d849959..55656a1 100644 --- a/src/transaction/types.ts +++ b/src/transaction/types.ts @@ -131,6 +131,13 @@ export interface ManageOfferParams extends MemoParams { buyingAssetIssuer?: string; amount: string; price: string; + /** + * Suppress warnings when `price` is outside the typical range + * (below 0.001 or above 1000 buying units per selling unit). + */ + suppressPriceWarnings?: boolean; + /** Override the default offer-price warning bounds (0.001–1000). */ + priceWarningRange?: { min?: number; max?: number }; offerId?: string; autoFetchSequence?: boolean; }