From 5e47a767f2c5df2548c943d391cc173673106f53 Mon Sep 17 00:00:00 2001 From: OluwapelumiElisha Date: Wed, 21 Oct 2026 04:16:49 +0100 Subject: [PATCH] fix(core): resolve issues #844, #849, #852, and #853 - fix(integration): add price oracle staleness validation and threshold checks (#844) - fix(shared): provide actionable recovery suggestions for SourceAccountNotFound (#849) - feat(soroban): add hit, miss, eviction, and memory tracking to LedgerEntryCache (#852) - feat(account): add minimum balance reserve calculation and minReserveAlert rule (#853) --- src/account/balanceAlerts.ts | 174 +++++++++--- src/account/getAccount.ts | 8 +- src/account/index.ts | 10 +- src/account/types.ts | 26 +- src/index.ts | 39 +++ src/integration/index.ts | 16 ++ src/integration/priceOracle.test.ts | 237 +++++++++++++++++ src/integration/priceOracle.ts | 311 ++++++++++++++++++++++ src/shared/response.ts | 82 +++++- src/soroban/index.ts | 10 + src/soroban/ledgerEntryCache.ts | 243 +++++++++++++++++ src/tests/balanceAlertsMinReserve.test.ts | 140 ++++++++++ src/tests/ledgerEntryCache.test.ts | 127 +++++++++ src/tests/sourceAccountNotFound.test.ts | 80 ++++++ src/transaction/atomicSwap.ts | 13 + src/transaction/buildHelpers.ts | 10 +- src/transaction/buildTransaction.ts | 8 +- 17 files changed, 1495 insertions(+), 39 deletions(-) create mode 100644 src/integration/priceOracle.test.ts create mode 100644 src/integration/priceOracle.ts create mode 100644 src/soroban/ledgerEntryCache.ts create mode 100644 src/tests/balanceAlertsMinReserve.test.ts create mode 100644 src/tests/ledgerEntryCache.test.ts create mode 100644 src/tests/sourceAccountNotFound.test.ts diff --git a/src/account/balanceAlerts.ts b/src/account/balanceAlerts.ts index d8adbe18..a7bba024 100644 --- a/src/account/balanceAlerts.ts +++ b/src/account/balanceAlerts.ts @@ -1,9 +1,95 @@ import type { + AccountReserveStructure, AssetBalance, BalanceAlert, BalanceAlertRule, } from "./types"; +/** + * Stellar protocol constants for reserve calculation. + * Base reserve is 0.5 XLM = 5,000,000 stroops. + * Base account reserves is 2 (account creation + master key). + */ +export const BASE_RESERVE_XLM = 0.5; +export const BASE_ACCOUNT_RESERVES = 2; +export const STROOPS_PER_XLM = 10_000_000n; + +/** + * Calculate the minimum required XLM balance (reserve) for an account structure. + * + * Reserve formula per Stellar protocol: + * Minimum Reserve (XLM) = (2 + subentries) × 0.5 XLM + * + * Subentries are tracked by Horizon and include: + * - Trustlines (non-native assets) + * - Offers + * - Additional signers (beyond master key) + * - Data entries (manage_data) + * + * @param structure - Account reserve context (subentryCount, trustlines, signers, etc.). + * @param baseReserveXlm - Base reserve per entry in XLM (default: 0.5 XLM). + * @returns Minimum reserve in XLM. + */ +export function calculateMinimumReserve( + structure?: AccountReserveStructure, + baseReserveXlm = BASE_RESERVE_XLM, +): number { + if (!structure) { + return BASE_ACCOUNT_RESERVES * baseReserveXlm; + } + + let subentries = 0; + if (typeof structure.subentryCount === "number" && structure.subentryCount >= 0) { + subentries = structure.subentryCount; + } else if (typeof structure.numSubentries === "number" && structure.numSubentries >= 0) { + subentries = structure.numSubentries; + } else { + if (Array.isArray(structure.trustlines)) subentries += structure.trustlines.length; + else if (typeof structure.trustlines === "number" && structure.trustlines > 0) subentries += structure.trustlines; + + if (Array.isArray(structure.signers)) subentries += Math.max(0, structure.signers.length - 1); + else if (typeof structure.signers === "number" && structure.signers > 0) subentries += Math.max(0, structure.signers - 1); + + if (Array.isArray(structure.offers)) subentries += structure.offers.length; + else if (typeof structure.offers === "number" && structure.offers > 0) subentries += structure.offers; + + if (Array.isArray(structure.dataEntries)) subentries += structure.dataEntries.length; + else if (typeof structure.dataEntries === "number" && structure.dataEntries > 0) subentries += structure.dataEntries; + } + + return (BASE_ACCOUNT_RESERVES + subentries) * baseReserveXlm; +} + +/** + * Calculate minimum reserve in stroops (1 XLM = 10,000,000 stroops). + */ +export function calculateMinimumReserveStroops( + structure?: AccountReserveStructure, + baseReserveXlm = BASE_RESERVE_XLM, +): bigint { + const xlm = calculateMinimumReserve(structure, baseReserveXlm); + return BigInt(Math.round(xlm * 10_000_000)); +} + +/** + * Convenience helper to create a minReserveAlert rule. + */ +export function minReserveAlert(options?: { + bufferXlm?: number; + threshold?: number; + id?: string; +}): BalanceAlertRule { + const buffer = options?.bufferXlm ?? options?.threshold ?? 0; + return { + id: options?.id ?? "min_reserve_alert", + assetCode: "XLM", + assetIssuer: null, + condition: "min_reserve", + threshold: buffer, + bufferXlm: buffer, + }; +} + /** * Determine if a numeric value represents a valid Stellar balance amount. * Valid balances must be non-negative finite numbers (>= 0 and not NaN). @@ -59,25 +145,14 @@ function findBalance( * alone decides — so an account that starts below a threshold alerts once. * - `change_percent` fires when the absolute percentage change between polls * meets the threshold. It requires a non-zero baseline. - * - * Handling of Invalid and Null Values (#747): - * - **Logged & Skipped**: If `newFloat` is null, NaN, negative, or non-finite, - * a warning is logged and the comparison is skipped to avoid false alerts or crashes. - * - **Baseline Guards**: If an existing baseline (`oldFloat`) is NaN, negative, - * or non-finite, a warning is logged and the comparison is skipped. - * - **Threshold Guards**: If `rule.threshold` is null, NaN, negative, or non-finite, - * a warning is logged and the rule is skipped. - * - **Consistent Null Baseline**: When no baseline exists (`oldFloat === null`), - * `below` and `above` evaluate against current balance alone (firing if initial - * state crosses threshold), whereas `change_percent` requires a non-zero baseline - * and consistently does not fire. - * - **No Errors Thrown**: All invalid states are safely handled by logging a warning - * and skipping without throwing, preserving stream stability. + * - `min_reserve` fires when native XLM balance approaches or drops below the + * calculated minimum reserve requirements for the account. */ export function evaluateBalanceAlerts( rules: BalanceAlertRule[], oldBalances: AssetBalance[], newBalances: AssetBalance[], + accountContext?: AccountReserveStructure, ): BalanceAlert[] { if (!Array.isArray(rules) || rules.length === 0) return []; if (!Array.isArray(newBalances) || newBalances.length === 0) return []; @@ -86,34 +161,52 @@ export function evaluateBalanceAlerts( const alerts: BalanceAlert[] = []; for (const rule of rules) { - if (!rule || typeof rule !== "object" || typeof rule.assetCode !== "string") { + if (!rule || typeof rule !== "object") { continue; } + const isMinReserve = rule.condition === "min_reserve"; + const assetCode = isMinReserve ? (rule.assetCode || "XLM") : rule.assetCode; + if (typeof assetCode !== "string") { + continue; + } + + const thresholdVal = isMinReserve + ? (rule.threshold ?? rule.bufferXlm ?? 0) + : rule.threshold; + if ( - typeof rule.threshold !== "number" || - Number.isNaN(rule.threshold) || - !Number.isFinite(rule.threshold) || - rule.threshold < 0 + typeof thresholdVal !== "number" || + Number.isNaN(thresholdVal) || + !Number.isFinite(thresholdVal) || + thresholdVal < 0 ) { console.warn( - `[sorokit] evaluateBalanceAlerts: invalid threshold for rule "${rule.id ?? rule.assetCode}" (value: ${rule.threshold}). Skipping rule.`, + `[sorokit] evaluateBalanceAlerts: invalid threshold for rule "${rule.id ?? assetCode}" (value: ${thresholdVal}). Skipping rule.`, ); continue; } - const newBal = findBalance(newBalances, rule.assetCode, rule.assetIssuer); + const newBal = findBalance( + newBalances, + assetCode, + isMinReserve ? null : rule.assetIssuer, + ); if (!newBal) continue; const newFloat = extractBalanceFloat(newBal); if (!isValidBalance(newFloat)) { console.warn( - `[sorokit] evaluateBalanceAlerts: invalid new balance for asset "${rule.assetCode}" (value: ${newFloat}). Skipping alert evaluation.`, + `[sorokit] evaluateBalanceAlerts: invalid new balance for asset "${assetCode}" (value: ${newFloat}). Skipping alert evaluation.`, ); continue; } - const oldBal = findBalance(safeOldBalances, rule.assetCode, rule.assetIssuer); + const oldBal = findBalance( + safeOldBalances, + assetCode, + isMinReserve ? null : rule.assetIssuer, + ); let oldFloat: number | null = null; if (oldBal !== undefined) { @@ -126,7 +219,7 @@ export function evaluateBalanceAlerts( const parsedOld = extractBalanceFloat(oldBal); if (!isValidBalance(parsedOld)) { console.warn( - `[sorokit] evaluateBalanceAlerts: invalid baseline balance for asset "${rule.assetCode}" (value: ${parsedOld}). Skipping alert evaluation.`, + `[sorokit] evaluateBalanceAlerts: invalid baseline balance for asset "${assetCode}" (value: ${parsedOld}). Skipping alert evaluation.`, ); continue; } @@ -136,32 +229,47 @@ export function evaluateBalanceAlerts( let fired = false; let changePercent: number | null = null; + let minReserve: number | undefined; switch (rule.condition) { case "below": fired = - newFloat < rule.threshold && - (oldFloat === null || oldFloat >= rule.threshold); + newFloat < thresholdVal && + (oldFloat === null || oldFloat >= thresholdVal); break; case "above": fired = - newFloat > rule.threshold && - (oldFloat === null || oldFloat <= rule.threshold); + newFloat > thresholdVal && + (oldFloat === null || oldFloat <= thresholdVal); break; case "change_percent": if (oldFloat !== null && oldFloat !== 0) { changePercent = ((newFloat - oldFloat) / oldFloat) * 100; - fired = Math.abs(changePercent) >= rule.threshold; + fired = Math.abs(changePercent) >= thresholdVal; } break; + case "min_reserve": { + minReserve = calculateMinimumReserve(accountContext); + const buffer = rule.bufferXlm ?? rule.threshold ?? 0; + const effectiveLimit = minReserve + buffer; + fired = + newFloat <= effectiveLimit && + (oldFloat === null || oldFloat > effectiveLimit); + break; + } default: console.warn( - `[sorokit] evaluateBalanceAlerts: unknown alert condition "${(rule as any).condition}" for asset "${rule.assetCode}". Skipping rule.`, + `[sorokit] evaluateBalanceAlerts: unknown alert condition "${(rule as any).condition}" for asset "${assetCode}". Skipping rule.`, ); continue; } if (fired) { + const minReserveStroops = + minReserve !== undefined + ? BigInt(Math.round(minReserve * 10_000_000)) + : undefined; + alerts.push({ rule, assetCode: newBal.assetCode, @@ -170,6 +278,11 @@ export function evaluateBalanceAlerts( oldBal?.balance != null ? String(oldBal.balance) : String(newBal.balance), newBalance: String(newBal.balance), changePercent, + ...(minReserve !== undefined ? { minimumReserve: minReserve } : {}), + ...(minReserveStroops !== undefined ? { minimumReserveStroops } : {}), + ...(isMinReserve && minReserve !== undefined + ? { isSubReserve: newFloat < minReserve } + : {}), }); } } @@ -177,3 +290,4 @@ export function evaluateBalanceAlerts( return alerts; } + diff --git a/src/account/getAccount.ts b/src/account/getAccount.ts index 9ca3a617..54296e5f 100644 --- a/src/account/getAccount.ts +++ b/src/account/getAccount.ts @@ -210,7 +210,13 @@ export function getAccount( : mapped.message || `Failed to fetch account: ${toMessage(cause)}`, cause, undefined, - mapped.recovery ? { recovery: mapped.recovery } : undefined, + { + context: { + operation: "account.get", + parameters: { publicKey }, + }, + ...(mapped.recovery ? { recovery: mapped.recovery } : {}), + }, ); } }), diff --git a/src/account/index.ts b/src/account/index.ts index 74a3b607..d390201c 100644 --- a/src/account/index.ts +++ b/src/account/index.ts @@ -20,7 +20,15 @@ export { getAssetBalances } from "./getAssetBalances"; export { getMultipleAssetBalances } from "./getMultipleAssetBalances"; export { streamAccount } from "./streamAccount"; export { subscribeToAccountEvents } from "./subscriptions"; -export { evaluateBalanceAlerts } from "./balanceAlerts"; +export { + evaluateBalanceAlerts, + calculateMinimumReserve, + calculateMinimumReserveStroops, + minReserveAlert, + BASE_RESERVE_XLM, + BASE_ACCOUNT_RESERVES, +} from "./balanceAlerts"; +export type { AccountReserveStructure } from "./types"; export { createBalanceAlert } from "./createBalanceAlert"; export { rotateAccountKey, diff --git a/src/account/types.ts b/src/account/types.ts index a483c42d..4edd5eae 100644 --- a/src/account/types.ts +++ b/src/account/types.ts @@ -59,15 +59,28 @@ export interface AssetBalance { * - `below` — fire when the balance drops below the threshold. * - `above` — fire when the balance rises above the threshold. * - `change_percent` — fire when the absolute % change between polls meets the threshold. + * - `min_reserve` — fire when the balance approaches or drops below the calculated minimum reserve. */ -export type BalanceAlertCondition = "below" | "above" | "change_percent"; +export type BalanceAlertCondition = "below" | "above" | "change_percent" | "min_reserve"; + +/** + * Account structure input used for calculating minimum reserve requirements. + */ +export interface AccountReserveStructure { + subentryCount?: number; + numSubentries?: number; + trustlines?: number | unknown[]; + signers?: number | unknown[]; + offers?: number | unknown[]; + dataEntries?: number | unknown[]; +} /** * A rule describing a balance condition worth alerting on. * Used by `streamAccount` to emit {@link BalanceAlert}s as balances change. */ export interface BalanceAlertRule { - /** Asset code to watch, e.g. "XLM" or "USDC". */ + /** Asset code to watch, e.g. "XLM" or "USDC". For "min_reserve", watches native "XLM". */ assetCode: string; /** * Optional issuer to disambiguate assets that share a code. @@ -80,8 +93,11 @@ export interface BalanceAlertRule { * Threshold value. * - For `below`/`above`: an absolute balance threshold. * - For `change_percent`: a percentage magnitude (e.g. `10` means 10%). + * - For `min_reserve`: buffer in XLM above the minimum reserve to warn early (defaults to 0). */ threshold: number; + /** Optional buffer in XLM above the reserve for min_reserve alerts. */ + bufferXlm?: number; /** Optional identifier echoed back on every alert produced by this rule. */ id?: string; } @@ -221,6 +237,12 @@ export interface BalanceAlert { newBalance: string; /** Signed percentage change since the previous poll, or null when not computable. */ changePercent: number | null; + /** Calculated minimum reserve in XLM when evaluating min_reserve rules. */ + minimumReserve?: number; + /** Minimum reserve in stroops (1 XLM = 10,000,000 stroops). */ + minimumReserveStroops?: bigint; + /** True when the balance has dropped below the calculated minimum reserve. */ + isSubReserve?: boolean; } export type { SponsorshipResult } from "./sponsorship"; diff --git a/src/index.ts b/src/index.ts index 51e00ee5..be98077c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -50,7 +50,21 @@ export { clearPendingChallenges, CHALLENGE_TTL_MS, MAX_PENDING_CHALLENGES, + PriceOracle, + createPriceOracle, + checkPriceFeedStaleness, + validatePriceFeedQuote, + normalizeTimestampToMs, + DEFAULT_PRICE_FEED_STALENESS_THRESHOLD_MS, } from "./integration"; +export type { + PriceFeedQuote, + PriceFeedProvider, + PriceOracleOptions, + StalePriceFeedAction, + PriceFeedValidationResult, +} from "./integration"; + export type { AnchorAsset, AnchorRequestOptions, @@ -2142,6 +2156,15 @@ export type { MemoryUsageSample, MemoryMonitorOptions, } from "./soroban/contractMemory"; +export { + LedgerEntryCache, + createLedgerEntryCache, +} from "./soroban/ledgerEntryCache"; +export type { + LedgerEntryCacheStats, + LedgerEntryCacheOptions, +} from "./soroban/ledgerEntryCache"; + // ─── Real-time transaction status dashboard (#708) ─────────────────────────── export { TransactionStatusAggregator, @@ -2171,3 +2194,19 @@ export type { WebSocketTransactionFeedOptions, WebSocketTransactionFeedHandle, } from "./transaction/transactionDashboard"; + +export { + evaluateBalanceAlerts, + calculateMinimumReserve, + calculateMinimumReserveStroops, + minReserveAlert, + BASE_RESERVE_XLM, + BASE_ACCOUNT_RESERVES, +} from "./account"; +export type { + BalanceAlert, + BalanceAlertRule, + BalanceAlertCondition, + AccountReserveStructure, +} from "./account"; + diff --git a/src/integration/index.ts b/src/integration/index.ts index 05f00157..8c59e75e 100644 --- a/src/integration/index.ts +++ b/src/integration/index.ts @@ -122,3 +122,19 @@ export type { DirectPaymentReceiver, DirectPaymentQuoteRequestOptions, } from "./sep31DirectPayment"; +export { + PriceOracle, + createPriceOracle, + checkPriceFeedStaleness, + validatePriceFeedQuote, + normalizeTimestampToMs, + DEFAULT_PRICE_FEED_STALENESS_THRESHOLD_MS, +} from "./priceOracle"; +export type { + PriceFeedQuote, + PriceFeedProvider, + PriceOracleOptions, + StalePriceFeedAction, + PriceFeedValidationResult, +} from "./priceOracle"; + diff --git a/src/integration/priceOracle.test.ts b/src/integration/priceOracle.test.ts new file mode 100644 index 00000000..be82132c --- /dev/null +++ b/src/integration/priceOracle.test.ts @@ -0,0 +1,237 @@ +import { describe, expect, it, vi } from "vitest"; +import { + PriceOracle, + createPriceOracle, + checkPriceFeedStaleness, + validatePriceFeedQuote, + normalizeTimestampToMs, + DEFAULT_PRICE_FEED_STALENESS_THRESHOLD_MS, +} from "./priceOracle"; +import { SorokitErrorCode } from "../shared/response"; + +describe("PriceOracle & Staleness Detection (#844)", () => { + const baseTime = 1_700_000_000_000; // Reference ms + + describe("normalizeTimestampToMs", () => { + it("parses epoch ms", () => { + expect(normalizeTimestampToMs(1_700_000_000_000)).toBe(1_700_000_000_000); + }); + + it("parses epoch seconds to ms", () => { + expect(normalizeTimestampToMs(1_700_000_000)).toBe(1_700_000_000_000); + }); + + it("parses Date object", () => { + const d = new Date(1_700_000_000_000); + expect(normalizeTimestampToMs(d)).toBe(1_700_000_000_000); + }); + + it("parses ISO date string", () => { + const iso = new Date(1_700_000_000_000).toISOString(); + expect(normalizeTimestampToMs(iso)).toBe(1_700_000_000_000); + }); + + it("returns null for invalid inputs", () => { + expect(normalizeTimestampToMs(undefined)).toBeNull(); + expect(normalizeTimestampToMs(null)).toBeNull(); + expect(normalizeTimestampToMs("not-a-date")).toBeNull(); + expect(normalizeTimestampToMs(NaN)).toBeNull(); + }); + }); + + describe("checkPriceFeedStaleness", () => { + it("flags fresh feeds within the 5-minute threshold", () => { + const quote = { + asset: "XLM", + price: 0.12, + timestamp: baseTime - 60_000, // 1 min old + }; + const result = checkPriceFeedStaleness(quote, DEFAULT_PRICE_FEED_STALENESS_THRESHOLD_MS, baseTime); + expect(result.isStale).toBe(false); + expect(result.ageMs).toBe(60_000); + }); + + it("flags feeds older than configurable threshold as stale", () => { + const quote = { + asset: "XLM", + price: 0.12, + timestamp: baseTime - 301_000, // 5 min 1 sec old + }; + const result = checkPriceFeedStaleness(quote, DEFAULT_PRICE_FEED_STALENESS_THRESHOLD_MS, baseTime); + expect(result.isStale).toBe(true); + expect(result.ageMs).toBe(301_000); + }); + + it("considers quotes without timestamp stale", () => { + const quote = { asset: "XLM", price: 0.12 }; + const result = checkPriceFeedStaleness(quote, DEFAULT_PRICE_FEED_STALENESS_THRESHOLD_MS, baseTime); + expect(result.isStale).toBe(true); + }); + }); + + describe("validatePriceFeedQuote", () => { + it("accepts fresh quotes", () => { + const quote = { + asset: "XLM", + price: 0.12, + timestamp: baseTime - 30_000, + }; + const res = validatePriceFeedQuote(quote, { + getCurrentTimeMs: () => baseTime, + }); + expect(res.status).toBe("ok"); + if (res.status === "ok") { + expect(res.data.status).toBe("fresh"); + } + }); + + it("rejects invalid prices", () => { + const quote = { + asset: "XLM", + price: -5, + timestamp: baseTime, + }; + const res = validatePriceFeedQuote(quote, { + getCurrentTimeMs: () => baseTime, + }); + expect(res.status).toBe("error"); + if (res.status === "error") { + expect(res.error.code).toBe(SorokitErrorCode.VALIDATION); + } + }); + + it("rejects stale quotes when onStale is reject (default)", () => { + const quote = { + asset: "XLM", + price: 0.12, + timestamp: baseTime - 400_000, // > 5 mins + }; + const res = validatePriceFeedQuote(quote, { + getCurrentTimeMs: () => baseTime, + }); + expect(res.status).toBe("error"); + if (res.status === "error") { + expect(res.error.code).toBe(SorokitErrorCode.SERVICE_UNAVAILABLE); + expect(res.error.message).toContain("stale"); + } + }); + + it("warns but accepts stale quotes when onStale is warn", () => { + const warnFn = vi.fn(); + const quote = { + asset: "XLM", + price: 0.12, + timestamp: baseTime - 400_000, + }; + const res = validatePriceFeedQuote(quote, { + onStale: "warn", + onStaleWarning: warnFn, + getCurrentTimeMs: () => baseTime, + }); + expect(res.status).toBe("ok"); + expect(warnFn).toHaveBeenCalledTimes(1); + if (res.status === "ok") { + expect(res.data.status).toBe("stale"); + } + }); + + it("respects custom stalenessThresholdMs", () => { + const quote = { + asset: "XLM", + price: 0.12, + timestamp: baseTime - 70_000, // 70 seconds old + }; + // 60 second threshold + const res = validatePriceFeedQuote(quote, { + stalenessThresholdMs: 60_000, + getCurrentTimeMs: () => baseTime, + }); + expect(res.status).toBe("error"); + if (res.status === "error") { + expect(res.error.message).toContain("stale"); + } + }); + }); + + describe("PriceOracle Class", () => { + it("fetches and validates fresh price quotes", async () => { + const mockProvider = { + getPrice: vi.fn().mockResolvedValue({ + asset: "XLM", + price: 0.15, + timestamp: baseTime - 10_000, + }), + }; + const oracle = createPriceOracle(mockProvider, { + getCurrentTimeMs: () => baseTime, + }); + + const res = await oracle.getPrice("XLM"); + expect(res.status).toBe("ok"); + if (res.status === "ok") { + expect(res.data.price).toBe(0.15); + } + }); + + it("rejects stale price feed from provider", async () => { + const mockProvider = { + getPrice: vi.fn().mockResolvedValue({ + asset: "XLM", + price: 0.15, + timestamp: baseTime - 600_000, // 10 min old + }), + }; + const oracle = createPriceOracle(mockProvider, { + getCurrentTimeMs: () => baseTime, + }); + + const res = await oracle.getPrice("XLM"); + expect(res.status).toBe("error"); + if (res.status === "error") { + expect(res.error.message).toContain("stale"); + } + }); + + it("estimates fee in target currency with fresh oracle price", async () => { + const mockProvider = { + getPrice: vi.fn().mockResolvedValue({ + asset: "XLM", + price: 0.2, // $0.20 per XLM + timestamp: baseTime - 10_000, + }), + }; + const oracle = new PriceOracle(mockProvider, { + getCurrentTimeMs: () => baseTime, + }); + + // 10,000,000 stroops = 1 XLM -> $0.20 + const res = await oracle.estimateFeeInCurrency(10_000_000, "USD"); + expect(res.status).toBe("ok"); + if (res.status === "ok") { + expect(res.data.feeInCurrency).toBeCloseTo(0.2); + expect(res.data.xlmPrice).toBe(0.2); + } + }); + + it("converts rate between assets safely", async () => { + const mockProvider = { + getPrice: vi.fn().mockImplementation(async (asset: string) => { + if (asset === "XLM") { + return { asset: "XLM", price: 0.1, timestamp: baseTime - 10_000 }; + } + return { asset: "USDC", price: 1.0, timestamp: baseTime - 10_000 }; + }), + }; + const oracle = new PriceOracle(mockProvider, { + getCurrentTimeMs: () => baseTime, + }); + + const res = await oracle.convertRate(100, "XLM", "USDC"); + expect(res.status).toBe("ok"); + if (res.status === "ok") { + expect(res.data.rate).toBeCloseTo(0.1); + expect(res.data.convertedAmount).toBeCloseTo(10); + } + }); + }); +}); diff --git a/src/integration/priceOracle.ts b/src/integration/priceOracle.ts new file mode 100644 index 00000000..b6db5eac --- /dev/null +++ b/src/integration/priceOracle.ts @@ -0,0 +1,311 @@ +import { err, ok, SorokitErrorCode } from "../shared/response"; +import type { SorokitResult } from "../shared/response"; + +/** + * Default threshold for price feed staleness: 5 minutes (300,000 ms). + */ +export const DEFAULT_PRICE_FEED_STALENESS_THRESHOLD_MS = 5 * 60 * 1000; + +export interface PriceFeedQuote { + /** Asset identifier or code (e.g. "XLM", "USDC"). */ + asset: string; + /** Counter currency (e.g. "USD"). Defaults to "USD". */ + currency?: string; + /** Quoted price. Must be a positive finite number. */ + price: number; + /** + * Timestamp of the price update. + * Can be epoch ms, epoch seconds (if < 1e11), Date, or ISO string. + */ + timestamp?: number | string | Date; + /** Alternative alias for timestamp. */ + updatedAt?: number | string | Date; + /** Estimated price impact percentage (0 to 100). */ + priceImpactPercent?: number; + /** Optional feed status or health. */ + status?: "fresh" | "stale" | string; + /** Optional confidence interval or uncertainty estimate. */ + confidence?: number; +} + +export type StalePriceFeedAction = "reject" | "warn"; + +export interface PriceOracleOptions { + /** + * Maximum acceptable age of price feed data in milliseconds. + * Defaults to 5 minutes (300,000 ms). + */ + stalenessThresholdMs?: number; + /** + * Action to take when a price feed is stale. + * - "reject": returns an error result. + * - "warn": returns an ok result but logs or invokes warning callback. + * Defaults to "reject". + */ + onStale?: StalePriceFeedAction; + /** Optional custom warning logger or callback when stale data is accepted with "warn". */ + onStaleWarning?: (warning: string, quote: PriceFeedQuote) => void; + /** Custom current time function for deterministic testing. */ + getCurrentTimeMs?: () => number; +} + +export interface PriceFeedValidationResult { + isStale: boolean; + ageMs: number; + quoteTimestampMs: number | null; + thresholdMs: number; +} + +/** + * Normalize timestamp into milliseconds epoch time. + */ +export function normalizeTimestampToMs( + timestamp?: number | string | Date, +): number | null { + if (timestamp === undefined || timestamp === null) return null; + if (timestamp instanceof Date) { + const ms = timestamp.getTime(); + return Number.isFinite(ms) ? ms : null; + } + if (typeof timestamp === "number") { + if (!Number.isFinite(timestamp) || timestamp < 0) return null; + // If timestamp is in seconds (less than 100 billion, i.e., before Nov 2286 in seconds) + if (timestamp < 1e11) { + return timestamp * 1000; + } + return timestamp; + } + if (typeof timestamp === "string") { + const parsed = Date.parse(timestamp); + return Number.isFinite(parsed) ? parsed : null; + } + return null; +} + +/** + * Check if a price feed quote is stale. + * + * @param quote - The price quote to evaluate. + * @param thresholdMs - Staleness threshold in ms (defaults to 5 minutes). + * @param currentTimeMs - Current time in ms (defaults to Date.now()). + */ +export function checkPriceFeedStaleness( + quote: PriceFeedQuote, + thresholdMs = DEFAULT_PRICE_FEED_STALENESS_THRESHOLD_MS, + currentTimeMs: number = Date.now(), +): PriceFeedValidationResult { + const rawTs = quote.timestamp ?? quote.updatedAt; + const quoteTimestampMs = normalizeTimestampToMs(rawTs); + + if (quoteTimestampMs === null) { + // Missing timestamp is considered indeterminate / stale by default + return { + isStale: true, + ageMs: Infinity, + quoteTimestampMs: null, + thresholdMs, + }; + } + + const ageMs = Math.max(0, currentTimeMs - quoteTimestampMs); + const isStale = ageMs > thresholdMs; + + return { + isStale, + ageMs, + quoteTimestampMs, + thresholdMs, + }; +} + +/** + * Validates a price feed quote, ensuring positive finite price and enforcing staleness rules. + */ +export function validatePriceFeedQuote( + quote: PriceFeedQuote, + options: PriceOracleOptions = {}, +): SorokitResult { + if (!quote || typeof quote !== "object") { + return err( + SorokitErrorCode.VALIDATION, + "Price feed quote is missing or invalid.", + ); + } + + if ( + typeof quote.price !== "number" || + !Number.isFinite(quote.price) || + quote.price <= 0 + ) { + return err( + SorokitErrorCode.VALIDATION, + `Price feed returned invalid price: ${quote.price}. Price must be a positive finite number.`, + ); + } + + const thresholdMs = + options.stalenessThresholdMs ?? DEFAULT_PRICE_FEED_STALENESS_THRESHOLD_MS; + const onStale = options.onStale ?? "reject"; + const now = options.getCurrentTimeMs ? options.getCurrentTimeMs() : Date.now(); + + const staleness = checkPriceFeedStaleness(quote, thresholdMs, now); + + if (staleness.isStale) { + const ageSec = (staleness.ageMs / 1000).toFixed(1); + const thresholdSec = (thresholdMs / 1000).toFixed(0); + const warningMessage = + `Price feed for ${quote.asset} is stale (age: ${ageSec}s exceeds threshold of ${thresholdSec}s).`; + + if (onStale === "reject") { + return err( + SorokitErrorCode.SERVICE_UNAVAILABLE, + warningMessage, + undefined, + undefined, + { + context: { + operation: "validatePriceFeedQuote", + parameters: { + asset: quote.asset, + price: quote.price, + ageMs: staleness.ageMs, + thresholdMs, + }, + }, + recovery: { + retryable: true, + action: "Refresh the price oracle feed or select an active oracle endpoint before proceeding.", + }, + }, + ); + } + + // On warn + if (options.onStaleWarning) { + options.onStaleWarning(warningMessage, quote); + } else { + console.warn(`[sorokit] ${warningMessage}`); + } + + return ok({ + ...quote, + status: "stale", + }); + } + + return ok({ + ...quote, + status: quote.status ?? "fresh", + }); +} + +/** + * Underlying interface for external price feed providers. + */ +export interface PriceFeedProvider { + getPrice(asset: string, currency?: string): Promise; +} + +/** + * Price Oracle client with built-in staleness detection and conversion helpers. + */ +export class PriceOracle { + private readonly provider: PriceFeedProvider; + private readonly options: PriceOracleOptions; + + constructor(provider: PriceFeedProvider, options: PriceOracleOptions = {}) { + this.provider = provider; + this.options = { + stalenessThresholdMs: DEFAULT_PRICE_FEED_STALENESS_THRESHOLD_MS, + onStale: "reject", + ...options, + }; + } + + /** + * Retrieve price quote for an asset, with automatic staleness check. + */ + async getPrice( + asset: string, + currency = "USD", + ): Promise> { + try { + const quote = await this.provider.getPrice(asset, currency); + return validatePriceFeedQuote(quote, this.options); + } catch (cause) { + return err( + SorokitErrorCode.SERVICE_UNAVAILABLE, + `Failed to fetch price quote from oracle for ${asset}: ${cause instanceof Error ? cause.message : String(cause)}`, + cause, + ); + } + } + + /** + * Estimate fee in counter-currency or native units using oracle prices. + * + * @param feeStroops - Fee in stroops (1 XLM = 10,000,000 stroops). + * @param targetCurrency - Target currency (e.g. "USD"). + */ + async estimateFeeInCurrency( + feeStroops: bigint | number | string, + targetCurrency = "USD", + ): Promise> { + const xlmQuoteResult = await this.getPrice("XLM", targetCurrency); + if (xlmQuoteResult.status === "error") { + return xlmQuoteResult; + } + + const stroops = BigInt(feeStroops); + const xlmAmount = Number(stroops) / 10_000_000; + const feeInCurrency = xlmAmount * xlmQuoteResult.data.price; + + return ok({ + feeInCurrency, + xlmPrice: xlmQuoteResult.data.price, + }); + } + + /** + * Convert an amount between two assets using verified fresh oracle prices. + */ + async convertRate( + amount: number, + fromAsset: string, + toAsset: string, + currency = "USD", + ): Promise> { + if (amount <= 0 || !Number.isFinite(amount)) { + return err( + SorokitErrorCode.VALIDATION, + "Amount must be a positive finite number.", + ); + } + + const [fromRes, toRes] = await Promise.all([ + this.getPrice(fromAsset, currency), + this.getPrice(toAsset, currency), + ]); + + if (fromRes.status === "error") return fromRes; + if (toRes.status === "error") return toRes; + + const rate = fromRes.data.price / toRes.data.price; + const convertedAmount = amount * rate; + + return ok({ + convertedAmount, + rate, + }); + } +} + +/** + * Create a new PriceOracle instance. + */ +export function createPriceOracle( + provider: PriceFeedProvider, + options?: PriceOracleOptions, +): PriceOracle { + return new PriceOracle(provider, options); +} diff --git a/src/shared/response.ts b/src/shared/response.ts index e2826798..b8d75a08 100644 --- a/src/shared/response.ts +++ b/src/shared/response.ts @@ -45,6 +45,7 @@ export interface RecoveryGuidance { retryable: boolean; action: string; retryAfterMs?: number; + suggestions?: string[]; } export interface SorokitError { @@ -54,6 +55,7 @@ export interface SorokitError { cause?: unknown; context?: SorokitErrorContext; recovery?: RecoveryGuidance; + suggestions?: string[]; traceId?: string; recoveryAttempts?: RecoveryAttempt[]; degradedMode?: boolean; @@ -62,6 +64,7 @@ export interface SorokitError { export interface SorokitErrorOptions { context?: SorokitErrorContext; recovery?: RecoveryGuidance; + suggestions?: string[]; } export enum SorokitErrorCode { @@ -72,6 +75,7 @@ export enum SorokitErrorCode { WALLET_SIGN_FAILED = "WALLET_SIGN_FAILED", WALLET_BROWSER_ONLY = "WALLET_BROWSER_ONLY", ACCOUNT_NOT_FOUND = "ACCOUNT_NOT_FOUND", + SOURCE_ACCOUNT_NOT_FOUND = "SOURCE_ACCOUNT_NOT_FOUND", ACCOUNT_FETCH_FAILED = "ACCOUNT_FETCH_FAILED", TX_BUILD_FAILED = "TX_BUILD_FAILED", TX_SIMULATE_FAILED = "TX_SIMULATE_FAILED", @@ -175,13 +179,38 @@ export function classifyError(code: SorokitErrorCode): SorokitErrorCategory { code.startsWith("ROUTER_") ) return SorokitErrorCategory.TRANSACTION; - if (code.startsWith("ACCOUNT")) return SorokitErrorCategory.INTERNAL; + if ( + code === SorokitErrorCode.SOURCE_ACCOUNT_NOT_FOUND || + code.startsWith("ACCOUNT") + ) { + return SorokitErrorCategory.INTERNAL; + } return SorokitErrorCategory.UNKNOWN; } +export const SOURCE_ACCOUNT_RECOVERY_SUGGESTIONS: readonly string[] = Object.freeze([ + "retry: Verify network connectivity and endpoint status, then retry if the account fetch was transiently unavailable.", + "wait: Wait for a pending account creation or funding transaction to confirm on-chain before retrying.", + "create account: Create and fund the account with minimum reserve (at least 1 XLM) on the Stellar network before submitting transactions.", + "use fallback: Use a fallback account or different active keypair with sufficient balance as the source account.", +]); + export function defaultRecoveryGuidance( category: SorokitErrorCategory, + code?: SorokitErrorCode, ): RecoveryGuidance { + if ( + code === SorokitErrorCode.SOURCE_ACCOUNT_NOT_FOUND || + code === SorokitErrorCode.ACCOUNT_NOT_FOUND + ) { + return { + retryable: true, + action: + "Account was not found on-ledger. Create/fund the account, wait for on-chain confirmation, or use a fallback account.", + suggestions: [...SOURCE_ACCOUNT_RECOVERY_SUGGESTIONS], + }; + } + switch (category) { case SorokitErrorCategory.NETWORK: return { @@ -233,6 +262,8 @@ export function err( options?: SorokitErrorOptions, ): SorokitResult { const category = classifyError(code); + const recovery = options?.recovery ?? defaultRecoveryGuidance(category, code); + const suggestions = options?.suggestions ?? recovery.suggestions; const error: SorokitError = { code, message, @@ -241,7 +272,8 @@ export function err( ...(options?.context ? { context: sanitizeErrorContext(options.context)! } : {}), - recovery: options?.recovery ?? defaultRecoveryGuidance(category), + recovery, + ...(suggestions !== undefined ? { suggestions } : {}), ...(traceId !== undefined ? { traceId } : {}), }; return Object.freeze({ @@ -251,6 +283,52 @@ export function err( }) as SorokitResult; } +export interface SourceAccountNotFoundOptions { + accountId?: string; + operation?: string; + cause?: unknown; + traceId?: string; + scenario?: "retry" | "wait" | "create_account" | "use_fallback" | "all"; +} + +/** + * Creates an error result for missing source accounts with actionable recovery suggestions. + */ +export function sourceAccountNotFoundError( + accountIdOrOptions?: string | SourceAccountNotFoundOptions, + options?: SourceAccountNotFoundOptions, +): SorokitResult { + const accountId = + typeof accountIdOrOptions === "string" + ? accountIdOrOptions + : accountIdOrOptions?.accountId; + const opts = + typeof accountIdOrOptions === "object" ? accountIdOrOptions : (options ?? {}); + + const suggestions = [...SOURCE_ACCOUNT_RECOVERY_SUGGESTIONS]; + return err( + SorokitErrorCode.SOURCE_ACCOUNT_NOT_FOUND, + accountId + ? `Source account not found: ${accountId}. The account must exist on-ledger and maintain minimum reserve.` + : "Source account not found on the network. The account must exist on-ledger and maintain minimum reserve.", + opts.cause, + opts.traceId, + { + context: { + operation: opts.operation ?? "resolveSourceAccount", + parameters: accountId ? { accountId } : undefined, + }, + recovery: { + retryable: true, + action: + "Account does not exist on-ledger. Create/fund the account, wait for on-chain confirmation, or use a fallback account.", + suggestions, + }, + suggestions, + }, + ); +} + export function attachTraceId( result: SorokitResult, traceId: string, diff --git a/src/soroban/index.ts b/src/soroban/index.ts index 2132f5a5..70a5f372 100644 --- a/src/soroban/index.ts +++ b/src/soroban/index.ts @@ -597,3 +597,13 @@ export type { UpgradeEvent, ContractUpgradeDetectionOptions, } from "./upgradeDetection"; + +export { + LedgerEntryCache, + createLedgerEntryCache, +} from "./ledgerEntryCache"; +export type { + LedgerEntryCacheStats, + LedgerEntryCacheOptions, +} from "./ledgerEntryCache"; + diff --git a/src/soroban/ledgerEntryCache.ts b/src/soroban/ledgerEntryCache.ts new file mode 100644 index 00000000..fda99898 --- /dev/null +++ b/src/soroban/ledgerEntryCache.ts @@ -0,0 +1,243 @@ +/** + * Statistics tracked by LedgerEntryCache. + */ +export interface LedgerEntryCacheStats { + /** Total number of successful cache hits. */ + hits: number; + /** Total number of cache misses (entry absent or expired). */ + misses: number; + /** Total number of cache evictions due to capacity limits or expiration. */ + evictions: number; + /** Estimated memory used by cached entries in bytes. */ + memoryUsed: number; + /** Cache hit rate between 0 and 1 (hits / (hits + misses)). */ + hitRate: number; + /** Current count of active entries in the cache. */ + size: number; + /** Maximum entry capacity, or null if unlimited. */ + capacity: number | null; +} + +export interface LedgerEntryCacheOptions { + /** Maximum number of entries before LRU eviction. Defaults to 1000. Set 0 or Infinity for unlimited. */ + maxEntries?: number; + /** Default TTL in milliseconds for entries without an explicit TTL. */ + defaultTtlMs?: number; + /** Callback fired when an entry is evicted. */ + onEvict?: (key: string, value: unknown) => void; +} + +interface CacheItem { + key: string; + value: T; + bytes: number; + expiresAt: number | null; + lastAccessed: number; +} + +/** + * Estimate size of an entry in bytes for memory tracking. + */ +function estimateEntrySizeBytes(key: string, value: unknown): number { + let bytes = key.length * 2; // UTF-16 characters + if (value === undefined || value === null) { + return bytes; + } + if (typeof value === "string") { + bytes += value.length * 2; + } else if (typeof value === "number") { + bytes += 8; + } else if (typeof value === "boolean") { + bytes += 4; + } else if (value instanceof Uint8Array || Buffer.isBuffer(value)) { + bytes += value.byteLength; + } else { + try { + bytes += JSON.stringify(value).length * 2; + } catch { + bytes += 128; // Fallback estimate + } + } + return bytes; +} + +/** + * LedgerEntryCache stores frequently queried Soroban ledger entries with + * full visibility into cache hits, misses, evictions, and memory usage. + */ +export class LedgerEntryCache { + private readonly store = new Map>(); + private readonly maxEntries: number; + private readonly defaultTtlMs?: number; + private readonly onEvict?: (key: string, value: unknown) => void; + + private hits = 0; + private misses = 0; + private evictions = 0; + private totalMemoryBytes = 0; + + constructor(options: LedgerEntryCacheOptions = {}) { + this.maxEntries = + options.maxEntries !== undefined && options.maxEntries >= 0 + ? options.maxEntries + : 1000; + this.defaultTtlMs = options.defaultTtlMs; + this.onEvict = options.onEvict; + } + + /** + * Look up an entry by key. + * Increments hits on success; increments misses if absent or expired. + */ + get(key: string): T | undefined { + const item = this.store.get(key); + if (!item) { + this.misses++; + return undefined; + } + + const now = Date.now(); + if (item.expiresAt !== null && now > item.expiresAt) { + // Expired entry + this.evictItem(key, item); + this.misses++; + return undefined; + } + + // Refresh access time (LRU) + item.lastAccessed = now; + // Map insertion order update for LRU + this.store.delete(key); + this.store.set(key, item); + + this.hits++; + return item.value; + } + + /** + * Store an entry in the cache. + * Evicts the least recently used entry if capacity would be exceeded. + */ + set(key: string, value: T, ttlMs?: number): void { + const now = Date.now(); + const effectiveTtl = ttlMs ?? this.defaultTtlMs; + const expiresAt = + effectiveTtl !== undefined && effectiveTtl > 0 ? now + effectiveTtl : null; + + const estimatedBytes = estimateEntrySizeBytes(key, value); + + // If key already exists, replace it and update memory + if (this.store.has(key)) { + const existing = this.store.get(key)!; + this.totalMemoryBytes -= existing.bytes; + this.store.delete(key); + } else if (this.maxEntries > 0 && this.store.size >= this.maxEntries) { + // Evict LRU (first key in map iteration order) + const oldestKey = this.store.keys().next().value; + if (oldestKey !== undefined) { + const oldestItem = this.store.get(oldestKey)!; + this.evictItem(oldestKey, oldestItem); + } + } + + const item: CacheItem = { + key, + value, + bytes: estimatedBytes, + expiresAt, + lastAccessed: now, + }; + + this.store.set(key, item); + this.totalMemoryBytes += estimatedBytes; + } + + /** + * Check if a key exists and has not expired (does not alter hit/miss stats). + */ + has(key: string): boolean { + const item = this.store.get(key); + if (!item) return false; + if (item.expiresAt !== null && Date.now() > item.expiresAt) { + this.evictItem(key, item); + return false; + } + return true; + } + + /** + * Remove a specific entry. + */ + delete(key: string): boolean { + const item = this.store.get(key); + if (!item) return false; + this.totalMemoryBytes = Math.max(0, this.totalMemoryBytes - item.bytes); + this.store.delete(key); + return true; + } + + /** + * Invalidate a key (alias for delete). + */ + invalidate(key: string): void { + this.delete(key); + } + + /** + * Clear all entries in the cache. Retains lifetime hit/miss statistics. + */ + clear(): void { + this.store.clear(); + this.totalMemoryBytes = 0; + } + + /** + * Retrieve aggregate cache performance and memory statistics. + */ + getStats(): LedgerEntryCacheStats { + const totalRequests = this.hits + this.misses; + const hitRate = totalRequests > 0 ? this.hits / totalRequests : 0; + + return { + hits: this.hits, + misses: this.misses, + evictions: this.evictions, + memoryUsed: this.totalMemoryBytes, + hitRate: Number(hitRate.toFixed(4)), + size: this.store.size, + capacity: this.maxEntries > 0 ? this.maxEntries : null, + }; + } + + /** + * Reset hit, miss, and eviction counters for benchmarking. + * Active cached entries are preserved. + */ + resetStats(): void { + this.hits = 0; + this.misses = 0; + this.evictions = 0; + } + + private evictItem(key: string, item: CacheItem): void { + this.store.delete(key); + this.totalMemoryBytes = Math.max(0, this.totalMemoryBytes - item.bytes); + this.evictions++; + if (this.onEvict) { + try { + this.onEvict(key, item.value); + } catch { + // Suppress callback errors during eviction + } + } + } +} + +/** + * Factory to create a LedgerEntryCache. + */ +export function createLedgerEntryCache( + options?: LedgerEntryCacheOptions, +): LedgerEntryCache { + return new LedgerEntryCache(options); +} diff --git a/src/tests/balanceAlertsMinReserve.test.ts b/src/tests/balanceAlertsMinReserve.test.ts new file mode 100644 index 00000000..c1e1b2df --- /dev/null +++ b/src/tests/balanceAlertsMinReserve.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from "vitest"; +import { + evaluateBalanceAlerts, + calculateMinimumReserve, + calculateMinimumReserveStroops, + minReserveAlert, + BASE_RESERVE_XLM, + BASE_ACCOUNT_RESERVES, +} from "../account/balanceAlerts"; +import type { AssetBalance, BalanceAlertRule } from "../account/types"; + +describe("BalanceAlertRule Minimum Reserve Tracking (#853)", () => { + const nativeBalance = (amount: number | string): AssetBalance => ({ + assetType: "native", + assetCode: "XLM", + assetIssuer: null, + balance: String(amount), + balanceFloat: typeof amount === "number" ? amount : parseFloat(amount), + }); + + describe("calculateMinimumReserve", () => { + it("calculates base reserve for an account with 0 subentries", () => { + // (2 + 0) * 0.5 XLM = 1.0 XLM + expect(calculateMinimumReserve()).toBe(1.0); + expect(calculateMinimumReserve({ subentryCount: 0 })).toBe(1.0); + expect(calculateMinimumReserveStroops({ subentryCount: 0 })).toBe(10_000_000n); + }); + + it("calculates minimum reserve based on subentryCount", () => { + // (2 + 3 subentries) * 0.5 XLM = 2.5 XLM = 25,000,000 stroops + expect(calculateMinimumReserve({ subentryCount: 3 })).toBe(2.5); + expect(calculateMinimumReserveStroops({ subentryCount: 3 })).toBe(25_000_000n); + }); + + it("calculates reserve from breakdown of trustlines, signers, offers, dataEntries", () => { + // 2 trustlines + 1 extra signer (2 total - 1 master) + 1 offer = 4 subentries + // (2 + 4) * 0.5 XLM = 3.0 XLM + const reserve = calculateMinimumReserve({ + trustlines: ["USDC", "BTC"], + signers: ["GMASTER", "GSIGNER2"], + offers: [101], + }); + expect(reserve).toBe(3.0); + expect(calculateMinimumReserveStroops({ + trustlines: ["USDC", "BTC"], + signers: ["GMASTER", "GSIGNER2"], + offers: [101], + })).toBe(30_000_000n); + }); + }); + + describe("minReserveAlert helper", () => { + it("creates a properly formatted BalanceAlertRule with condition min_reserve", () => { + const rule = minReserveAlert({ bufferXlm: 2 }); + expect(rule.condition).toBe("min_reserve"); + expect(rule.assetCode).toBe("XLM"); + expect(rule.bufferXlm).toBe(2); + expect(rule.threshold).toBe(2); + }); + }); + + describe("evaluateBalanceAlerts with min_reserve rule", () => { + it("fires when balance approaches minimum reserve within buffer", () => { + // Account with 2 subentries -> reserve = (2 + 2) * 0.5 = 2.0 XLM. + // Rule has buffer = 1.0 XLM -> alerts when balance <= 3.0 XLM. + const rule: BalanceAlertRule = { + id: "low-reserve-buffer", + assetCode: "XLM", + condition: "min_reserve", + threshold: 1.0, + }; + + const accountContext = { subentryCount: 2 }; + + // Transition from 5 XLM -> 2.8 XLM (approaching reserve + buffer) + const alerts = evaluateBalanceAlerts( + [rule], + [nativeBalance(5.0)], + [nativeBalance(2.8)], + accountContext, + ); + + expect(alerts).toHaveLength(1); + expect(alerts[0].rule.id).toBe("low-reserve-buffer"); + expect(alerts[0].minimumReserve).toBe(2.0); + expect(alerts[0].minimumReserveStroops).toBe(20_000_000n); + expect(alerts[0].isSubReserve).toBe(false); // 2.8 > 2.0 (approaching reserve) + }); + + it("fires and marks isSubReserve=true when balance drops strictly below minimum reserve", () => { + // Account with 4 subentries -> reserve = (2 + 4) * 0.5 = 3.0 XLM. + const rule: BalanceAlertRule = minReserveAlert({ bufferXlm: 0 }); + const accountContext = { subentryCount: 4 }; + + // Transition from 4.0 XLM -> 2.5 XLM (below 3.0 XLM reserve!) + const alerts = evaluateBalanceAlerts( + [rule], + [nativeBalance(4.0)], + [nativeBalance(2.5)], + accountContext, + ); + + expect(alerts).toHaveLength(1); + expect(alerts[0].minimumReserve).toBe(3.0); + expect(alerts[0].isSubReserve).toBe(true); + expect(alerts[0].newBalance).toBe("2.5"); + expect(alerts[0].oldBalance).toBe("4"); + }); + + it("does not fire when balance stays above the reserve + buffer threshold", () => { + const rule = minReserveAlert({ bufferXlm: 0.5 }); + const accountContext = { subentryCount: 0 }; // reserve = 1.0 XLM, threshold = 1.5 XLM + + // 10 XLM -> 5 XLM (well above 1.5 XLM) + const alerts = evaluateBalanceAlerts( + [rule], + [nativeBalance(10.0)], + [nativeBalance(5.0)], + accountContext, + ); + + expect(alerts).toHaveLength(0); + }); + + it("does not repeatedly fire if balance remains below the threshold without crossing", () => { + const rule = minReserveAlert(); + const accountContext = { subentryCount: 0 }; // reserve = 1.0 XLM + + // Balance already below reserve: 0.8 XLM -> 0.7 XLM + const alerts = evaluateBalanceAlerts( + [rule], + [nativeBalance(0.8)], + [nativeBalance(0.7)], + accountContext, + ); + + expect(alerts).toHaveLength(0); + }); + }); +}); diff --git a/src/tests/ledgerEntryCache.test.ts b/src/tests/ledgerEntryCache.test.ts new file mode 100644 index 00000000..3958f3d8 --- /dev/null +++ b/src/tests/ledgerEntryCache.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it, vi } from "vitest"; +import { + LedgerEntryCache, + createLedgerEntryCache, +} from "../soroban/ledgerEntryCache"; + +describe("LedgerEntryCache Statistics & Management (#852)", () => { + it("initializes with zeroed statistics", () => { + const cache = new LedgerEntryCache({ maxEntries: 10 }); + const stats = cache.getStats(); + + expect(stats.hits).toBe(0); + expect(stats.misses).toBe(0); + expect(stats.evictions).toBe(0); + expect(stats.memoryUsed).toBe(0); + expect(stats.hitRate).toBe(0); + expect(stats.size).toBe(0); + expect(stats.capacity).toBe(10); + }); + + it("accurately tracks hits and misses", () => { + const cache = createLedgerEntryCache({ maxEntries: 5 }); + + // Miss on empty cache + expect(cache.get("key1")).toBeUndefined(); + expect(cache.getStats().misses).toBe(1); + expect(cache.getStats().hits).toBe(0); + + // Set key1 and hit it + cache.set("key1", "val1"); + expect(cache.get("key1")).toBe("val1"); + expect(cache.getStats().hits).toBe(1); + expect(cache.getStats().misses).toBe(1); + expect(cache.getStats().hitRate).toBe(0.5); // 1 / 2 + + // Another hit + expect(cache.get("key1")).toBe("val1"); + expect(cache.getStats().hits).toBe(2); + expect(cache.getStats().hitRate).toBe(0.6667); // 2 / 3 + }); + + it("tracks memoryUsed when entries are added, overwritten, and deleted", () => { + const cache = new LedgerEntryCache(); + + cache.set("k1", "small"); + const mem1 = cache.getStats().memoryUsed; + expect(mem1).toBeGreaterThan(0); + + // Overwriting updates memory size + cache.set("k1", "a much longer string value with more bytes"); + const mem2 = cache.getStats().memoryUsed; + expect(mem2).toBeGreaterThan(mem1); + + // Delete reduces memoryUsed + cache.delete("k1"); + expect(cache.getStats().memoryUsed).toBe(0); + }); + + it("tracks evictions when maxEntries limit is exceeded (LRU)", () => { + const onEvict = vi.fn(); + const cache = new LedgerEntryCache({ + maxEntries: 2, + onEvict, + }); + + cache.set("a", 1); + cache.set("b", 2); + expect(cache.getStats().size).toBe(2); + expect(cache.getStats().evictions).toBe(0); + + // Access "a" so "b" becomes the least recently used + cache.get("a"); + + // Adding "c" should evict "b" + cache.set("c", 3); + expect(cache.getStats().evictions).toBe(1); + expect(cache.getStats().size).toBe(2); + expect(onEvict).toHaveBeenCalledWith("b", 2); + + expect(cache.has("b")).toBe(false); + expect(cache.has("a")).toBe(true); + expect(cache.has("c")).toBe(true); + }); + + it("tracks evictions and misses when entries expire", () => { + vi.useFakeTimers(); + try { + const cache = new LedgerEntryCache({ defaultTtlMs: 1000 }); + cache.set("expiring", "value"); + + expect(cache.get("expiring")).toBe("value"); + expect(cache.getStats().hits).toBe(1); + + // Advance past TTL + vi.advanceTimersByTime(1500); + + expect(cache.get("expiring")).toBeUndefined(); + expect(cache.getStats().misses).toBe(1); + expect(cache.getStats().evictions).toBe(1); + } finally { + vi.useRealTimers(); + } + }); + + it("allows resetting stats for benchmarking without clearing cached entries", () => { + const cache = new LedgerEntryCache({ maxEntries: 10 }); + cache.set("k1", "v1"); + cache.get("k1"); // hit + cache.get("k2"); // miss + + expect(cache.getStats().hits).toBe(1); + expect(cache.getStats().misses).toBe(1); + expect(cache.getStats().size).toBe(1); + + cache.resetStats(); + const resetStats = cache.getStats(); + expect(resetStats.hits).toBe(0); + expect(resetStats.misses).toBe(0); + expect(resetStats.evictions).toBe(0); + expect(resetStats.hitRate).toBe(0); + // Entry is preserved + expect(resetStats.size).toBe(1); + expect(resetStats.memoryUsed).toBeGreaterThan(0); + expect(cache.get("k1")).toBe("v1"); + expect(cache.getStats().hits).toBe(1); + }); +}); diff --git a/src/tests/sourceAccountNotFound.test.ts b/src/tests/sourceAccountNotFound.test.ts new file mode 100644 index 00000000..fd3f398e --- /dev/null +++ b/src/tests/sourceAccountNotFound.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import { + err, + SorokitErrorCode, + sourceAccountNotFoundError, + SOURCE_ACCOUNT_RECOVERY_SUGGESTIONS, + defaultRecoveryGuidance, + classifyError, + SorokitErrorCategory, +} from "../shared/response"; + +describe("SourceAccountNotFound & Recovery Guidance (#849)", () => { + it("defines SOURCE_ACCOUNT_NOT_FOUND code in SorokitErrorCode", () => { + expect(SorokitErrorCode.SOURCE_ACCOUNT_NOT_FOUND).toBe("SOURCE_ACCOUNT_NOT_FOUND"); + expect(classifyError(SorokitErrorCode.SOURCE_ACCOUNT_NOT_FOUND)).toBe(SorokitErrorCategory.INTERNAL); + }); + + it("includes all 4 key recovery suggestions (retry, wait, create account, use fallback)", () => { + const suggestions = SOURCE_ACCOUNT_RECOVERY_SUGGESTIONS; + expect(suggestions.length).toBeGreaterThanOrEqual(4); + + const joined = suggestions.join(" ").toLowerCase(); + expect(joined).toContain("retry"); + expect(joined).toContain("wait"); + expect(joined).toContain("create"); + expect(joined).toContain("fallback"); + }); + + it("defaultRecoveryGuidance returns recovery suggestions for ACCOUNT_NOT_FOUND and SOURCE_ACCOUNT_NOT_FOUND", () => { + const guidanceSource = defaultRecoveryGuidance( + SorokitErrorCategory.INTERNAL, + SorokitErrorCode.SOURCE_ACCOUNT_NOT_FOUND, + ); + expect(guidanceSource.retryable).toBe(true); + expect(guidanceSource.suggestions).toBeDefined(); + expect(guidanceSource.suggestions?.length).toBeGreaterThanOrEqual(4); + + const guidanceAccount = defaultRecoveryGuidance( + SorokitErrorCategory.INTERNAL, + SorokitErrorCode.ACCOUNT_NOT_FOUND, + ); + expect(guidanceAccount.retryable).toBe(true); + expect(guidanceAccount.suggestions).toBeDefined(); + expect(guidanceAccount.suggestions?.length).toBeGreaterThanOrEqual(4); + }); + + it("sourceAccountNotFoundError constructs error result with rich context and recovery suggestions", () => { + const publicKey = "GA2C5RFPE6GCKMY3US5PAB6UZLKIGAHWKXX2GIOVPVUIV255KZG42CA7"; + const result = sourceAccountNotFoundError(publicKey, { + operation: "buildPaymentTransaction", + }); + + expect(result.status).toBe("error"); + if (result.status === "error") { + expect(result.error.code).toBe(SorokitErrorCode.SOURCE_ACCOUNT_NOT_FOUND); + expect(result.error.message).toContain(publicKey); + expect(result.error.context?.operation).toBe("buildPaymentTransaction"); + expect(result.error.context?.parameters).toEqual({ accountId: publicKey }); + expect(result.error.recovery?.retryable).toBe(true); + expect(result.error.recovery?.suggestions).toBeDefined(); + expect(result.error.suggestions).toBeDefined(); + expect(result.error.suggestions?.some((s) => s.includes("retry"))).toBe(true); + expect(result.error.suggestions?.some((s) => s.includes("wait"))).toBe(true); + expect(result.error.suggestions?.some((s) => s.includes("create"))).toBe(true); + expect(result.error.suggestions?.some((s) => s.includes("fallback"))).toBe(true); + } + }); + + it("err helper automatically attaches suggestions for ACCOUNT_NOT_FOUND", () => { + const result = err( + SorokitErrorCode.ACCOUNT_NOT_FOUND, + "Account not found on network", + ); + expect(result.status).toBe("error"); + if (result.status === "error") { + expect(result.error.recovery?.suggestions).toBeDefined(); + expect(result.error.suggestions).toBeDefined(); + } + }); +}); diff --git a/src/transaction/atomicSwap.ts b/src/transaction/atomicSwap.ts index 2d7240b3..0a47f2b8 100644 --- a/src/transaction/atomicSwap.ts +++ b/src/transaction/atomicSwap.ts @@ -170,11 +170,24 @@ async function buildPriceProtectedSwap( options.priceFeed.getPrice(buyingCode, "USD"), ]); + const now = Date.now(); + const thresholdMs = 5 * 60 * 1000; + const isQuoteStale = (q: any) => { + const ts = q.timestamp ?? q.updatedAt; + if (ts !== undefined && ts !== null) { + const ms = typeof ts === "number" ? (ts < 1e11 ? ts * 1000 : ts) : (ts instanceof Date ? ts.getTime() : Date.parse(String(ts))); + if (Number.isFinite(ms) && now - ms > thresholdMs) return true; + } + return false; + }; + if ( !sellingQuote || !buyingQuote || (sellingQuote.status !== undefined && sellingQuote.status !== "fresh") || (buyingQuote.status !== undefined && buyingQuote.status !== "fresh") || + isQuoteStale(sellingQuote) || + isQuoteStale(buyingQuote) || typeof sellingQuote.price !== "number" || !Number.isFinite(sellingQuote.price) || sellingQuote.price <= 0 || diff --git a/src/transaction/buildHelpers.ts b/src/transaction/buildHelpers.ts index 6530c960..a971a38d 100644 --- a/src/transaction/buildHelpers.ts +++ b/src/transaction/buildHelpers.ts @@ -7,9 +7,9 @@ */ import { Account, Asset, Memo, BASE_FEE } from "@stellar/stellar-sdk"; -import { ok, err, SorokitErrorCode } from "../shared/response"; +import { ok, err, SorokitErrorCode, sourceAccountNotFoundError } from "../shared/response"; import type { SorokitResult } from "../shared/response"; -import { toMessage } from "../shared"; +import { toMessage, isNotFoundError } from "../shared"; import { createHorizonServer } from "../shared/serverFactory"; import { validateMemoPolicy } from "./buildTransaction"; import type { MemoParams } from "./types"; @@ -52,6 +52,12 @@ export async function resolveSourceAccount( const sourceAccount = await server.loadAccount(sourcePublicKey); return ok(sourceAccount); } catch (cause) { + if (isNotFoundError(cause)) { + return sourceAccountNotFoundError(sourcePublicKey, { + cause, + operation: "resolveSourceAccount", + }); + } return err( SorokitErrorCode.TX_BUILD_FAILED, `Failed to load source account: ${toMessage(cause)}`, diff --git a/src/transaction/buildTransaction.ts b/src/transaction/buildTransaction.ts index b443f9a0..0ae8ea3e 100644 --- a/src/transaction/buildTransaction.ts +++ b/src/transaction/buildTransaction.ts @@ -8,7 +8,7 @@ import { Account, StrKey, } from "@stellar/stellar-sdk"; -import { ok, err, SorokitErrorCode } from "../shared/response"; +import { ok, err, SorokitErrorCode, sourceAccountNotFoundError } from "../shared/response"; import type { SorokitResult } from "../shared/response"; import { validateIssuer } from "../shared/validateIssuer"; @@ -121,6 +121,12 @@ async function resolveSourceAccount( const sourceAccount = await server.loadAccount(sourcePublicKey); return ok(sourceAccount); } catch (cause) { + if (isNotFoundError(cause)) { + return sourceAccountNotFoundError(sourcePublicKey, { + cause, + operation: "resolveSourceAccount", + }); + } return err( SorokitErrorCode.TX_BUILD_FAILED, `Failed to load source account: ${toMessage(cause)}`,