diff --git a/packages/stellar/src/dex-orderbook-snapshot.test.ts b/packages/stellar/src/dex-orderbook-snapshot.test.ts new file mode 100644 index 00000000..b591b163 --- /dev/null +++ b/packages/stellar/src/dex-orderbook-snapshot.test.ts @@ -0,0 +1,298 @@ +/** + * DEX Order Book Snapshot Tests for Price Feed Validation (Issue #091) + * + * Validates that computeDexPrice correctly interprets Stellar order book data + * and computes accurate prices across various market conditions. + * + * Fixtures use the same shape as Horizon's order book API response so they + * can be swapped for live snapshots without modifying the price logic. + */ + +import { describe, it, expect } from 'vitest'; +import { + computeDexPrice, + computeVwap, + assertPriceClose, + PRICE_TOLERANCE, + type OrderBookSnapshot, +} from './dex-price-feed'; + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +/** Thin market: a single bid and a single ask far apart. */ +const THIN_BOOK: OrderBookSnapshot = { + bids: [ + { price: '0.4000000', amount: '100.0000000', price_r: { n: 2, d: 5 } }, + ], + asks: [ + { price: '0.6000000', amount: '100.0000000', price_r: { n: 3, d: 5 } }, + ], +}; + +/** Deep market: many levels, tight spread. */ +const DEEP_BOOK: OrderBookSnapshot = { + bids: [ + { price: '0.4998000', amount: '5000.0000000', price_r: { n: 4998, d: 10000 } }, + { price: '0.4990000', amount: '10000.0000000', price_r: { n: 499, d: 1000 } }, + { price: '0.4970000', amount: '20000.0000000', price_r: { n: 497, d: 1000 } }, + ], + asks: [ + { price: '0.5002000', amount: '5000.0000000', price_r: { n: 5002, d: 10000 } }, + { price: '0.5010000', amount: '10000.0000000', price_r: { n: 501, d: 1000 } }, + { price: '0.5030000', amount: '20000.0000000', price_r: { n: 503, d: 1000 } }, + ], +}; + +/** Empty order book: no bids, no asks. */ +const EMPTY_BOOK: OrderBookSnapshot = { bids: [], asks: [] }; + +/** Single-sided: bids only (no asks). */ +const BIDS_ONLY_BOOK: OrderBookSnapshot = { + bids: [{ price: '0.5000000', amount: '500.0000000', price_r: { n: 1, d: 2 } }], + asks: [], +}; + +/** Single-sided: asks only (no bids). */ +const ASKS_ONLY_BOOK: OrderBookSnapshot = { + bids: [], + asks: [{ price: '0.5000000', amount: '500.0000000', price_r: { n: 1, d: 2 } }], +}; + +/** Crossed market: best bid exceeds best ask (invalid state). */ +const CROSSED_BOOK: OrderBookSnapshot = { + bids: [{ price: '0.6000000', amount: '200.0000000', price_r: { n: 3, d: 5 } }], + asks: [{ price: '0.4000000', amount: '200.0000000', price_r: { n: 2, d: 5 } }], +}; + +/** Exact mid-point book: symmetric spread around 1.0. */ +const SYMMETRIC_BOOK: OrderBookSnapshot = { + bids: [{ price: '0.9900000', amount: '1000.0000000', price_r: { n: 99, d: 100 } }], + asks: [{ price: '1.0100000', amount: '1000.0000000', price_r: { n: 101, d: 100 } }], +}; + +// ── Thin market ─────────────────────────────────────────────────────────────── + +describe('thin order book', () => { + it('returns correct bestBid and bestAsk', () => { + const price = computeDexPrice(THIN_BOOK); + expect(price.bestBid).toBeDefined(); + expect(price.bestAsk).toBeDefined(); + assertPriceClose(price.bestBid!, 0.4); + assertPriceClose(price.bestAsk!, 0.6); + }); + + it('computes midPrice as arithmetic mean', () => { + const price = computeDexPrice(THIN_BOOK); + expect(price.midPrice).toBeDefined(); + assertPriceClose(price.midPrice!, 0.5); + }); + + it('computes spread correctly', () => { + const price = computeDexPrice(THIN_BOOK); + expect(price.spread).toBeDefined(); + assertPriceClose(price.spread!, 0.2); + }); + + it('computes spreadPercent (spread / midPrice * 100)', () => { + const price = computeDexPrice(THIN_BOOK); + expect(price.spreadPercent).toBeDefined(); + assertPriceClose(price.spreadPercent!, 40.0); // 0.2 / 0.5 * 100 + }); + + it('is not empty and not crossed', () => { + const price = computeDexPrice(THIN_BOOK); + expect(price.empty).toBe(false); + expect(price.crossed).toBe(false); + }); +}); + +// ── Deep market ─────────────────────────────────────────────────────────────── + +describe('deep order book', () => { + it('uses top-of-book price (not averaged)', () => { + const price = computeDexPrice(DEEP_BOOK); + assertPriceClose(price.bestBid!, 0.4998); + assertPriceClose(price.bestAsk!, 0.5002); + }); + + it('has a tight midPrice near 0.5', () => { + const price = computeDexPrice(DEEP_BOOK); + assertPriceClose(price.midPrice!, 0.5, 1e-4); + }); + + it('has a small spread (< 1 %)', () => { + const price = computeDexPrice(DEEP_BOOK); + expect(price.spreadPercent).toBeDefined(); + expect(price.spreadPercent!).toBeLessThan(1.0); + }); + + it('is not empty and not crossed', () => { + const price = computeDexPrice(DEEP_BOOK); + expect(price.empty).toBe(false); + expect(price.crossed).toBe(false); + }); +}); + +// ── Empty order book ────────────────────────────────────────────────────────── + +describe('empty order book', () => { + it('marks the book as empty', () => { + const price = computeDexPrice(EMPTY_BOOK); + expect(price.empty).toBe(true); + }); + + it('returns undefined for all price fields', () => { + const price = computeDexPrice(EMPTY_BOOK); + expect(price.bestBid).toBeUndefined(); + expect(price.bestAsk).toBeUndefined(); + expect(price.midPrice).toBeUndefined(); + expect(price.spread).toBeUndefined(); + expect(price.spreadPercent).toBeUndefined(); + }); + + it('is not crossed', () => { + expect(computeDexPrice(EMPTY_BOOK).crossed).toBe(false); + }); +}); + +// ── Single-sided order books ────────────────────────────────────────────────── + +describe('bids-only order book', () => { + it('returns bestBid but no bestAsk', () => { + const price = computeDexPrice(BIDS_ONLY_BOOK); + expect(price.bestBid).toBeDefined(); + assertPriceClose(price.bestBid!, 0.5); + expect(price.bestAsk).toBeUndefined(); + }); + + it('cannot compute midPrice, spread, or spreadPercent', () => { + const price = computeDexPrice(BIDS_ONLY_BOOK); + expect(price.midPrice).toBeUndefined(); + expect(price.spread).toBeUndefined(); + expect(price.spreadPercent).toBeUndefined(); + }); + + it('is not empty and not crossed', () => { + const price = computeDexPrice(BIDS_ONLY_BOOK); + expect(price.empty).toBe(false); + expect(price.crossed).toBe(false); + }); +}); + +describe('asks-only order book', () => { + it('returns bestAsk but no bestBid', () => { + const price = computeDexPrice(ASKS_ONLY_BOOK); + expect(price.bestAsk).toBeDefined(); + assertPriceClose(price.bestAsk!, 0.5); + expect(price.bestBid).toBeUndefined(); + }); + + it('cannot compute midPrice or spread', () => { + const price = computeDexPrice(ASKS_ONLY_BOOK); + expect(price.midPrice).toBeUndefined(); + expect(price.spread).toBeUndefined(); + }); +}); + +// ── Crossed market ──────────────────────────────────────────────────────────── + +describe('crossed order book', () => { + it('detects the crossed condition', () => { + const price = computeDexPrice(CROSSED_BOOK); + expect(price.crossed).toBe(true); + }); + + it('still computes midPrice even when crossed', () => { + const price = computeDexPrice(CROSSED_BOOK); + expect(price.midPrice).toBeDefined(); + // midPrice = (0.6 + 0.4) / 2 = 0.5 + assertPriceClose(price.midPrice!, 0.5); + }); + + it('has a negative spread (bestAsk < bestBid)', () => { + const price = computeDexPrice(CROSSED_BOOK); + expect(price.spread).toBeDefined(); + expect(price.spread!).toBeLessThan(0); + }); +}); + +// ── Symmetric mid-point ─────────────────────────────────────────────────────── + +describe('symmetric book (spread equidistant from 1.0)', () => { + it('midPrice is exactly 1.0', () => { + const price = computeDexPrice(SYMMETRIC_BOOK); + assertPriceClose(price.midPrice!, 1.0); + }); + + it('spread is 0.02', () => { + const price = computeDexPrice(SYMMETRIC_BOOK); + assertPriceClose(price.spread!, 0.02); + }); + + it('spreadPercent is 2 %', () => { + const price = computeDexPrice(SYMMETRIC_BOOK); + assertPriceClose(price.spreadPercent!, 2.0); + }); +}); + +// ── PRICE_TOLERANCE constant ────────────────────────────────────────────────── + +describe('PRICE_TOLERANCE', () => { + it('is defined and small', () => { + expect(PRICE_TOLERANCE).toBeGreaterThan(0); + expect(PRICE_TOLERANCE).toBeLessThan(1e-5); + }); +}); + +// ── assertPriceClose ────────────────────────────────────────────────────────── + +describe('assertPriceClose', () => { + it('does not throw when prices match within tolerance', () => { + expect(() => assertPriceClose(0.5000001, 0.5)).not.toThrow(); + }); + + it('throws when deviation exceeds tolerance', () => { + expect(() => assertPriceClose(0.6, 0.5)).toThrow(); + }); +}); + +// ── computeVwap ─────────────────────────────────────────────────────────────── + +describe('computeVwap', () => { + it('returns undefined for an empty level list', () => { + expect(computeVwap([])).toBeUndefined(); + }); + + it('returns single-level price when only one level', () => { + const levels = [{ price: '0.5', amount: '100', price_r: { n: 1, d: 2 } }]; + expect(computeVwap(levels)).toBeCloseTo(0.5, 7); + }); + + it('weights levels by volume', () => { + const levels = [ + { price: '1.0', amount: '100', price_r: { n: 1, d: 1 } }, + { price: '2.0', amount: '100', price_r: { n: 2, d: 1 } }, + ]; + // VWAP = (1.0*100 + 2.0*100) / 200 = 1.5 + expect(computeVwap(levels)).toBeCloseTo(1.5, 7); + }); + + it('respects maxVolume cap', () => { + const levels = [ + { price: '1.0', amount: '100', price_r: { n: 1, d: 1 } }, + { price: '3.0', amount: '100', price_r: { n: 3, d: 1 } }, + ]; + // With maxVolume=100, only first level is consumed → VWAP = 1.0 + expect(computeVwap(levels, 100)).toBeCloseTo(1.0, 7); + }); + + it('handles partial consumption of a level', () => { + const levels = [ + { price: '1.0', amount: '100', price_r: { n: 1, d: 1 } }, + { price: '2.0', amount: '100', price_r: { n: 2, d: 1 } }, + ]; + // maxVolume=150: consume all 100 at 1.0, then 50 at 2.0 + // VWAP = (100*1.0 + 50*2.0) / 150 = 200/150 ≈ 1.3333 + expect(computeVwap(levels, 150)).toBeCloseTo(200 / 150, 7); + }); +}); diff --git a/packages/stellar/src/dex-price-feed.ts b/packages/stellar/src/dex-price-feed.ts new file mode 100644 index 00000000..024d4c37 --- /dev/null +++ b/packages/stellar/src/dex-price-feed.ts @@ -0,0 +1,161 @@ +/** + * Stellar DEX Price Feed — Order Book Price Computation (Issue #091) + * + * Computes price metrics from Stellar Horizon DEX order book snapshots. + * All functions are pure and stateless; no network calls are made here. + * + * ## Price metrics returned + * - bestBid / bestAsk: top-of-book prices (highest bid, lowest ask) + * - midPrice: arithmetic mean of bestBid and bestAsk + * - spread: absolute difference (bestAsk − bestBid) + * - spreadPercent: spread as a percentage of midPrice + * + * ## Edge-case handling + * | Condition | Behaviour | + * |---------------------|-------------------------------------------------| + * | Empty order book | midPrice, spread, spreadPercent all undefined | + * | Bids only | bestAsk, midPrice, spread, spreadPercent undefined | + * | Asks only | bestBid, midPrice, spread, spreadPercent undefined | + * | Crossed book | midPrice computed, `crossed: true` flag set | + */ + +// ── Input types (mirror Horizon order book API) ─────────────────────────────── + +export interface OrderBookLevel { + /** Price as a decimal string, e.g. "0.5000000" */ + price: string; + /** Volume at this price level as a decimal string */ + amount: string; + /** Rational representation { n, d } where price = n / d */ + price_r: { n: number; d: number }; +} + +export interface OrderBookSnapshot { + /** Bids sorted descending by price (best bid first). */ + bids: OrderBookLevel[]; + /** Asks sorted ascending by price (best ask first). */ + asks: OrderBookLevel[]; +} + +// ── Output types ────────────────────────────────────────────────────────────── + +export interface DexPriceResult { + /** Highest bid price (undefined when no bids). */ + bestBid: number | undefined; + /** Lowest ask price (undefined when no asks). */ + bestAsk: number | undefined; + /** + * Arithmetic mid-price = (bestBid + bestAsk) / 2. + * Defined only when both sides are present. + */ + midPrice: number | undefined; + /** + * Absolute spread = bestAsk − bestBid. + * Defined only when both sides are present. + */ + spread: number | undefined; + /** + * Spread as a percentage of midPrice. + * Defined only when both sides are present and midPrice > 0. + */ + spreadPercent: number | undefined; + /** true when bestBid >= bestAsk (invalid/crossed market). */ + crossed: boolean; + /** true when both bids and asks arrays are empty. */ + empty: boolean; +} + +// ── Price tolerance ─────────────────────────────────────────────────────────── + +/** + * Maximum relative deviation allowed when asserting price accuracy in tests. + * Value of 1e-6 (1 part per million) covers floating-point rounding across + * the seven decimal places used by Horizon's fixed-point format. + */ +export const PRICE_TOLERANCE = 1e-6; + +// ── Core computation ────────────────────────────────────────────────────────── + +/** + * Compute price metrics from a Stellar DEX order book snapshot. + * + * @param book - An order book snapshot (bids + asks arrays) + * @returns Computed price metrics; fields that cannot be derived are `undefined` + * + * @example + * ```typescript + * const book = await server.orderbook(selling, buying).call(); + * const price = computeDexPrice(book); + * if (!price.empty) { + * console.log('Mid price:', price.midPrice); + * } + * ``` + */ +export function computeDexPrice(book: OrderBookSnapshot): DexPriceResult { + const bestBid = topPrice(book.bids); + const bestAsk = topPrice(book.asks); + const empty = bestBid === undefined && bestAsk === undefined; + const crossed = bestBid !== undefined && bestAsk !== undefined && bestBid >= bestAsk; + + let midPrice: number | undefined; + let spread: number | undefined; + let spreadPercent: number | undefined; + + if (bestBid !== undefined && bestAsk !== undefined) { + midPrice = (bestBid + bestAsk) / 2; + spread = bestAsk - bestBid; + spreadPercent = midPrice > 0 ? (spread / midPrice) * 100 : undefined; + } + + return { bestBid, bestAsk, midPrice, spread, spreadPercent, crossed, empty }; +} + +/** + * Compute the volume-weighted average price (VWAP) for one side of the book + * up to a given depth (in quote asset volume). + * + * @param levels - Sorted price levels (bids desc, asks asc) + * @param maxVolume - Maximum base-asset volume to consume (unbounded when omitted) + * @returns VWAP across consumed levels, or `undefined` when levels is empty + */ +export function computeVwap(levels: OrderBookLevel[], maxVolume?: number): number | undefined { + if (levels.length === 0) return undefined; + + let weightedSum = 0; + let totalVolume = 0; + const limit = maxVolume ?? Infinity; + + for (const level of levels) { + const price = parseFloat(level.price); + const amount = parseFloat(level.amount); + if (!isFinite(price) || !isFinite(amount) || amount <= 0) continue; + + const consumed = Math.min(amount, limit - totalVolume); + weightedSum += price * consumed; + totalVolume += consumed; + if (totalVolume >= limit) break; + } + + return totalVolume > 0 ? weightedSum / totalVolume : undefined; +} + +/** + * Assert that two prices are within `PRICE_TOLERANCE` relative error. + * Useful in snapshot tests to account for floating-point rounding. + */ +export function assertPriceClose(actual: number, expected: number, tolerance = PRICE_TOLERANCE): void { + const relErr = Math.abs(actual - expected) / (Math.abs(expected) || 1); + if (relErr > tolerance) { + throw new Error( + `Price assertion failed: actual=${actual}, expected=${expected}, relErr=${relErr.toExponential(3)}`, + ); + } +} + +// ── Internal helpers ────────────────────────────────────────────────────────── + +function topPrice(levels: OrderBookLevel[]): number | undefined { + if (levels.length === 0) return undefined; + const p = parseFloat(levels[0].price); + return isFinite(p) ? p : undefined; +} diff --git a/packages/stellar/src/index.ts b/packages/stellar/src/index.ts index ce75066a..718f9fe4 100644 --- a/packages/stellar/src/index.ts +++ b/packages/stellar/src/index.ts @@ -7,3 +7,7 @@ export * from './soroban'; export * from './soroban-migration'; export * from './soroban-event-relay'; export * from './trustline-validation'; +export * from './soroban-budget-monitor'; +export * from './soroban-xdr-deserializer'; +export * from './dex-price-feed'; +export * from './soroban-ttl-manager'; diff --git a/packages/stellar/src/soroban-budget-monitor.test.ts b/packages/stellar/src/soroban-budget-monitor.test.ts new file mode 100644 index 00000000..98928cda --- /dev/null +++ b/packages/stellar/src/soroban-budget-monitor.test.ts @@ -0,0 +1,196 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { SorobanRpc } from 'stellar-sdk'; +import { + trackContractBudget, + extractBudgetFromSimulation, + onBudgetAlert, + clearBudgetMetrics, + getBudgetMetrics, + SOROBAN_CPU_INSN_LIMIT, + SOROBAN_MEMORY_LIMIT_BYTES, + DEFAULT_ALERT_THRESHOLD, +} from './soroban-budget-monitor'; + +const CONTRACT_ID = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM'; +const SOURCE_KEY = 'GCEZWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCGSNFHEYVXM3XOJMDS674JZ'; + +function makeSimulation(cpuInsns: string, memBytes: string): SorobanRpc.Api.SimulateTransactionResponse { + return { + cost: { cpuInsns, memBytes }, + minResourceFee: '100', + } as unknown as SorobanRpc.Api.SimulateTransactionResponse; +} + +beforeEach(() => { + clearBudgetMetrics(); +}); + +describe('trackContractBudget', () => { + it('returns BudgetUsage with correct fractions', async () => { + const cpuInsns = String(Math.floor(SOROBAN_CPU_INSN_LIMIT * 0.5)); + const memBytes = String(Math.floor(SOROBAN_MEMORY_LIMIT_BYTES * 0.25)); + const mockSimulate = vi.fn().mockResolvedValue(makeSimulation(cpuInsns, memBytes)); + + const usage = await trackContractBudget(CONTRACT_ID, 'ping', [], SOURCE_KEY, {}, mockSimulate); + + expect(usage).not.toBeNull(); + expect(usage!.cpuInsns).toBe(BigInt(cpuInsns)); + expect(usage!.memoryBytes).toBe(BigInt(memBytes)); + expect(usage!.cpuLimitFraction).toBeCloseTo(0.5, 5); + expect(usage!.memoryLimitFraction).toBeCloseTo(0.25, 5); + }); + + it('sets cpuAlert=false below threshold', async () => { + const mockSimulate = vi.fn().mockResolvedValue( + makeSimulation(String(SOROBAN_CPU_INSN_LIMIT * 0.79), '0'), + ); + + const usage = await trackContractBudget(CONTRACT_ID, 'ping', [], SOURCE_KEY, {}, mockSimulate); + + expect(usage!.cpuAlert).toBe(false); + }); + + it('sets cpuAlert=true at exactly the threshold', async () => { + const atThreshold = String(Math.floor(SOROBAN_CPU_INSN_LIMIT * DEFAULT_ALERT_THRESHOLD)); + const mockSimulate = vi.fn().mockResolvedValue(makeSimulation(atThreshold, '0')); + + const usage = await trackContractBudget(CONTRACT_ID, 'ping', [], SOURCE_KEY, {}, mockSimulate); + + expect(usage!.cpuAlert).toBe(true); + }); + + it('sets memoryAlert=true when memory exceeds threshold', async () => { + const overThreshold = String(Math.floor(SOROBAN_MEMORY_LIMIT_BYTES * 0.9)); + const mockSimulate = vi.fn().mockResolvedValue(makeSimulation('0', overThreshold)); + + const usage = await trackContractBudget(CONTRACT_ID, 'ping', [], SOURCE_KEY, {}, mockSimulate); + + expect(usage!.memoryAlert).toBe(true); + }); + + it('respects custom thresholds', async () => { + const half = String(SOROBAN_CPU_INSN_LIMIT * 0.6); + const mockSimulate = vi.fn().mockResolvedValue(makeSimulation(half, '0')); + + const usage = await trackContractBudget( + CONTRACT_ID, 'ping', [], SOURCE_KEY, + { cpuFraction: 0.5 }, + mockSimulate, + ); + + expect(usage!.cpuAlert).toBe(true); + }); + + it('returns null when simulation has no cost field', async () => { + const mockSimulate = vi.fn().mockResolvedValue({ + minResourceFee: '100', + } as unknown as SorobanRpc.Api.SimulateTransactionResponse); + + const usage = await trackContractBudget(CONTRACT_ID, 'ping', [], SOURCE_KEY, {}, mockSimulate); + + expect(usage).toBeNull(); + }); + + it('stores metric in the metrics store', async () => { + const mockSimulate = vi.fn().mockResolvedValue(makeSimulation('1000000', '512000')); + await trackContractBudget(CONTRACT_ID, 'transfer', [], SOURCE_KEY, {}, mockSimulate); + + const metrics = getBudgetMetrics(); + expect(metrics).toHaveLength(1); + expect(metrics[0].contractId).toBe(CONTRACT_ID); + expect(metrics[0].method).toBe('transfer'); + }); +}); + +describe('alert handler', () => { + it('fires handler when CPU threshold is exceeded', async () => { + const handler = vi.fn(); + const off = onBudgetAlert(handler); + + const overThreshold = String(Math.floor(SOROBAN_CPU_INSN_LIMIT * 0.85)); + const mockSimulate = vi.fn().mockResolvedValue(makeSimulation(overThreshold, '0')); + + await trackContractBudget(CONTRACT_ID, 'heavyOp', [], SOURCE_KEY, {}, mockSimulate); + + expect(handler).toHaveBeenCalledOnce(); + expect(handler.mock.calls[0][0].usage.cpuAlert).toBe(true); + off(); + }); + + it('fires handler when memory threshold is exceeded', async () => { + const handler = vi.fn(); + const off = onBudgetAlert(handler); + + const overMem = String(Math.floor(SOROBAN_MEMORY_LIMIT_BYTES * 0.9)); + const mockSimulate = vi.fn().mockResolvedValue(makeSimulation('0', overMem)); + + await trackContractBudget(CONTRACT_ID, 'bigAlloc', [], SOURCE_KEY, {}, mockSimulate); + + expect(handler).toHaveBeenCalledOnce(); + expect(handler.mock.calls[0][0].usage.memoryAlert).toBe(true); + off(); + }); + + it('does not fire handler when both resources are below threshold', async () => { + const handler = vi.fn(); + const off = onBudgetAlert(handler); + + const mockSimulate = vi.fn().mockResolvedValue(makeSimulation('100', '1024')); + await trackContractBudget(CONTRACT_ID, 'cheapOp', [], SOURCE_KEY, {}, mockSimulate); + + expect(handler).not.toHaveBeenCalled(); + off(); + }); + + it('unsubscribe stops the handler from being called', async () => { + const handler = vi.fn(); + const off = onBudgetAlert(handler); + off(); + + const over = String(SOROBAN_CPU_INSN_LIMIT); + const mockSimulate = vi.fn().mockResolvedValue(makeSimulation(over, '0')); + await trackContractBudget(CONTRACT_ID, 'op', [], SOURCE_KEY, {}, mockSimulate); + + expect(handler).not.toHaveBeenCalled(); + }); +}); + +describe('extractBudgetFromSimulation', () => { + it('computes usage without an RPC call', () => { + const sim = makeSimulation('50000000', '20000000'); + const usage = extractBudgetFromSimulation(sim); + + expect(usage).not.toBeNull(); + expect(usage!.cpuInsns).toBe(50_000_000n); + expect(usage!.memoryBytes).toBe(20_000_000n); + expect(usage!.cpuAlert).toBe(false); + expect(usage!.memoryAlert).toBe(false); + }); + + it('returns null for simulation without cost', () => { + const usage = extractBudgetFromSimulation( + { error: 'failed' } as unknown as SorobanRpc.Api.SimulateTransactionResponse, + ); + expect(usage).toBeNull(); + }); +}); + +describe('getBudgetMetrics + clearBudgetMetrics', () => { + it('accumulates metrics across multiple calls', async () => { + const mockSimulate = vi.fn().mockResolvedValue(makeSimulation('1000', '512')); + + await trackContractBudget(CONTRACT_ID, 'a', [], SOURCE_KEY, {}, mockSimulate); + await trackContractBudget(CONTRACT_ID, 'b', [], SOURCE_KEY, {}, mockSimulate); + + expect(getBudgetMetrics()).toHaveLength(2); + }); + + it('clearBudgetMetrics empties the store', async () => { + const mockSimulate = vi.fn().mockResolvedValue(makeSimulation('1000', '512')); + await trackContractBudget(CONTRACT_ID, 'x', [], SOURCE_KEY, {}, mockSimulate); + + clearBudgetMetrics(); + + expect(getBudgetMetrics()).toHaveLength(0); + }); +}); diff --git a/packages/stellar/src/soroban-budget-monitor.ts b/packages/stellar/src/soroban-budget-monitor.ts new file mode 100644 index 00000000..0492cda4 --- /dev/null +++ b/packages/stellar/src/soroban-budget-monitor.ts @@ -0,0 +1,205 @@ +/** + * Soroban Contract Execution Budget Monitoring (Issue #089) + * + * Tracks CPU instruction count and memory bytes from contract simulation + * responses and fires configurable alert handlers when usage approaches + * Soroban protocol hard limits. + * + * ## Metrics exposed + * - cpuInsns: CPU instructions consumed by the invocation + * - memoryBytes: Memory consumed in bytes + * - cpuLimitFraction: fraction of the 100 M instruction ceiling + * - memoryLimitFraction: fraction of the 40 MB memory ceiling + * + * ## Alert flow + * Register a handler with `onBudgetAlert`. It fires whenever either + * resource meets or exceeds the configured threshold (default 80 %). + * + * @see https://developers.stellar.org/docs/smart-contracts/resource-limits-fees + */ + +import type { SorobanRpc } from 'stellar-sdk'; +import { xdr } from 'stellar-sdk'; +import { simulateContractCall } from './soroban'; + +// ── Soroban Protocol 21 hard limits ────────────────────────────────────────── + +/** Maximum CPU instructions per Soroban transaction. */ +export const SOROBAN_CPU_INSN_LIMIT = 100_000_000; + +/** Maximum memory in bytes per Soroban transaction (40 MB). */ +export const SOROBAN_MEMORY_LIMIT_BYTES = 41_943_040; + +/** Default fraction (0–1) of a hard limit that triggers an alert. */ +export const DEFAULT_ALERT_THRESHOLD = 0.8; + +// ── Public types ────────────────────────────────────────────────────────────── + +export interface BudgetThresholds { + /** Fraction 0–1 of the CPU limit at which to alert. Default: 0.8 */ + cpuFraction?: number; + /** Fraction 0–1 of the memory limit at which to alert. Default: 0.8 */ + memoryFraction?: number; +} + +export interface BudgetUsage { + cpuInsns: bigint; + memoryBytes: bigint; + /** cpuInsns / SOROBAN_CPU_INSN_LIMIT */ + cpuLimitFraction: number; + /** memoryBytes / SOROBAN_MEMORY_LIMIT_BYTES */ + memoryLimitFraction: number; + /** true when cpuLimitFraction >= configured threshold */ + cpuAlert: boolean; + /** true when memoryLimitFraction >= configured threshold */ + memoryAlert: boolean; +} + +export interface BudgetMetric { + contractId: string; + method: string; + usage: BudgetUsage; + /** Unix timestamp (ms) when this metric was recorded. */ + timestamp: number; +} + +/** Called when one or both budget thresholds are breached. */ +export type BudgetAlertHandler = (metric: BudgetMetric) => void; + +// ── Module-level state (ring-buffer + handlers) ─────────────────────────────── + +const MAX_STORED_METRICS = 1_000; +const metricsStore: BudgetMetric[] = []; +const alertHandlers: BudgetAlertHandler[] = []; + +// ── Public API ──────────────────────────────────────────────────────────────── + +/** + * Register a handler invoked whenever a CPU or memory threshold is breached. + * Returns an unsubscribe function. + * + * @example + * ```typescript + * const off = onBudgetAlert((m) => { + * if (m.usage.cpuAlert) logger.warn('CPU budget alert', m); + * }); + * off(); // deregister + * ``` + */ +export function onBudgetAlert(handler: BudgetAlertHandler): () => void { + alertHandlers.push(handler); + return () => { + const idx = alertHandlers.indexOf(handler); + if (idx !== -1) alertHandlers.splice(idx, 1); + }; +} + +/** + * Flush all recorded budget metrics. + * Call in test teardown to ensure isolation between test cases. + */ +export function clearBudgetMetrics(): void { + metricsStore.length = 0; +} + +/** + * Return a read-only snapshot of all recorded budget metrics (newest last). + */ +export function getBudgetMetrics(): readonly BudgetMetric[] { + return metricsStore; +} + +/** + * Simulate a contract invocation, record its execution budget, and fire + * alert handlers if CPU or memory usage meets or exceeds the configured + * thresholds. + * + * @param contractId - The contract address (C...) + * @param method - Contract method name + * @param args - XDR-encoded method arguments + * @param sourcePublicKey - Source account public key + * @param thresholds - Optional alert thresholds (default: 80 % of hard limit) + * @param _simulate - Override `simulateContractCall` for unit testing + * @returns `BudgetUsage` when cost data is present in the simulation, `null` + * when the simulation response does not include cost information + * + * @example + * ```typescript + * const usage = await trackContractBudget(contractId, 'transfer', args, pubKey); + * if (usage?.cpuAlert) { + * console.warn(`CPU at ${(usage.cpuLimitFraction * 100).toFixed(1)}% of limit`); + * } + * ``` + */ +export async function trackContractBudget( + contractId: string, + method: string, + args: xdr.ScVal[], + sourcePublicKey: string, + thresholds: BudgetThresholds = {}, + _simulate: typeof simulateContractCall = simulateContractCall, +): Promise { + const resolved = resolveThresholds(thresholds); + const simulation = await _simulate(contractId, method, args, sourcePublicKey); + const usage = extractBudgetUsage(simulation, resolved); + if (!usage) return null; + + pushMetric({ contractId, method, usage, timestamp: Date.now() }); + return usage; +} + +/** + * Extract execution budget from an existing simulation response without + * triggering an additional RPC call. + * + * @returns `BudgetUsage` when cost data is present, `null` otherwise + */ +export function extractBudgetFromSimulation( + simulation: SorobanRpc.Api.SimulateTransactionResponse, + thresholds: BudgetThresholds = {}, +): BudgetUsage | null { + return extractBudgetUsage(simulation, resolveThresholds(thresholds)); +} + +// ── Internal helpers ────────────────────────────────────────────────────────── + +function resolveThresholds(t: BudgetThresholds): Required { + return { + cpuFraction: t.cpuFraction ?? DEFAULT_ALERT_THRESHOLD, + memoryFraction: t.memoryFraction ?? DEFAULT_ALERT_THRESHOLD, + }; +} + +function extractBudgetUsage( + simulation: SorobanRpc.Api.SimulateTransactionResponse, + thresholds: Required, +): BudgetUsage | null { + if (!('cost' in simulation) || !simulation.cost) return null; + + const cpuInsns = BigInt(simulation.cost.cpuInsns ?? '0'); + const memoryBytes = BigInt(simulation.cost.memBytes ?? '0'); + const cpuLimitFraction = Number(cpuInsns) / SOROBAN_CPU_INSN_LIMIT; + const memoryLimitFraction = Number(memoryBytes) / SOROBAN_MEMORY_LIMIT_BYTES; + + return { + cpuInsns, + memoryBytes, + cpuLimitFraction, + memoryLimitFraction, + cpuAlert: cpuLimitFraction >= thresholds.cpuFraction, + memoryAlert: memoryLimitFraction >= thresholds.memoryFraction, + }; +} + +function pushMetric(metric: BudgetMetric): void { + if (metricsStore.length >= MAX_STORED_METRICS) { + metricsStore.shift(); + } + metricsStore.push(metric); + + if (metric.usage.cpuAlert || metric.usage.memoryAlert) { + for (const handler of alertHandlers) { + handler(metric); + } + } +} diff --git a/packages/stellar/src/soroban-ttl-manager.test.ts b/packages/stellar/src/soroban-ttl-manager.test.ts new file mode 100644 index 00000000..71205d37 --- /dev/null +++ b/packages/stellar/src/soroban-ttl-manager.test.ts @@ -0,0 +1,286 @@ +import { describe, it, expect, vi } from 'vitest'; +import { xdr, Account, Contract } from 'stellar-sdk'; +import { + getLedgerEntryTtl, + buildTtlExtensionTransaction, + buildContractInstanceKey, + buildContractDataKey, + checkContractTtl, + DEFAULT_WARNING_LEDGERS, + DEFAULT_EXTEND_TO_LEDGERS, +} from './soroban-ttl-manager'; + +const CONTRACT_ID = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM'; +const SOURCE_KEY = 'GCEZWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCGSNFHEYVXM3XOJMDS674JZ'; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function makeKey(contractId: string = CONTRACT_ID): xdr.LedgerKey { + return buildContractInstanceKey(contractId); +} + +function makeTtlClient(liveUntil: number | null, currentLedger: number) { + const key = makeKey(); + return { + getLatestLedger: vi.fn().mockResolvedValue({ sequence: currentLedger }), + getLedgerEntries: vi.fn().mockResolvedValue({ + entries: liveUntil !== null + ? [{ key, xdr: {} as xdr.LedgerEntry, liveUntilLedgerSeq: liveUntil }] + : [], + latestLedger: currentLedger, + }), + }; +} + +function makeTxClient() { + const fakeAccount = new Account(SOURCE_KEY, '1'); + const fakeTx = { toXDR: vi.fn().mockReturnValue('prepared-tx-xdr') }; + return { + getAccount: vi.fn().mockResolvedValue(fakeAccount), + prepareTransaction: vi.fn().mockResolvedValue(fakeTx), + }; +} + +// ── buildContractInstanceKey ────────────────────────────────────────────────── + +describe('buildContractInstanceKey', () => { + it('returns an xdr.LedgerKey', () => { + const key = buildContractInstanceKey(CONTRACT_ID); + expect(key).toBeInstanceOf(xdr.LedgerKey); + }); + + it('produces the same key as Contract.getFootprint()', () => { + const key = buildContractInstanceKey(CONTRACT_ID); + const expected = new Contract(CONTRACT_ID).getFootprint(); + expect(key.toXDR('base64')).toBe(expected.toXDR('base64')); + }); +}); + +// ── buildContractDataKey ────────────────────────────────────────────────────── + +describe('buildContractDataKey', () => { + it('returns a persistent contract-data ledger key', () => { + const storageKey = xdr.ScVal.scvSymbol('counter'); + const key = buildContractDataKey(CONTRACT_ID, storageKey); + expect(key).toBeInstanceOf(xdr.LedgerKey); + expect(key.switch().name).toBe('contractData'); + expect(key.contractData().durability().name).toBe('persistent'); + }); +}); + +// ── getLedgerEntryTtl ───────────────────────────────────────────────────────── + +describe('getLedgerEntryTtl', () => { + it('returns correct remaining ledgers for a healthy entry', async () => { + const currentLedger = 1000; + const liveUntil = 2500; + const client = makeTtlClient(liveUntil, currentLedger); + const key = makeKey(); + + const [info] = await getLedgerEntryTtl([key], {}, client); + + expect(info.currentLedger).toBe(currentLedger); + expect(info.liveUntilLedger).toBe(liveUntil); + expect(info.remainingLedgers).toBe(1500); + expect(info.isExpired).toBe(false); + expect(info.isNearExpiration).toBe(false); + }); + + it('sets isNearExpiration when remaining <= warningLedgers', async () => { + const currentLedger = 1000; + const liveUntil = 1000 + DEFAULT_WARNING_LEDGERS - 1; + const client = makeTtlClient(liveUntil, currentLedger); + const key = makeKey(); + + const [info] = await getLedgerEntryTtl([key], {}, client); + + expect(info.isNearExpiration).toBe(true); + expect(info.isExpired).toBe(false); + }); + + it('sets isNearExpiration=false at exactly the warning boundary', async () => { + const currentLedger = 1000; + const liveUntil = 1000 + DEFAULT_WARNING_LEDGERS; + const client = makeTtlClient(liveUntil, currentLedger); + const key = makeKey(); + + const [info] = await getLedgerEntryTtl([key], {}, client); + + expect(info.isNearExpiration).toBe(false); + }); + + it('sets isExpired when liveUntilLedger <= currentLedger', async () => { + const currentLedger = 2000; + const liveUntil = 2000; + const client = makeTtlClient(liveUntil, currentLedger); + const key = makeKey(); + + const [info] = await getLedgerEntryTtl([key], {}, client); + + expect(info.isExpired).toBe(true); + expect(info.isNearExpiration).toBe(false); + }); + + it('handles missing entry (entry not returned by node)', async () => { + const client = makeTtlClient(null, 1000); + const key = makeKey(); + + const [info] = await getLedgerEntryTtl([key], {}, client); + + expect(info.liveUntilLedger).toBeNull(); + expect(info.remainingLedgers).toBeNull(); + expect(info.isExpired).toBe(false); + expect(info.isNearExpiration).toBe(false); + }); + + it('respects custom warningLedgers threshold', async () => { + const currentLedger = 1000; + const liveUntil = 1500; + const client = makeTtlClient(liveUntil, currentLedger); + const key = makeKey(); + + const [info] = await getLedgerEntryTtl([key], { warningLedgers: 600 }, client); + + expect(info.isNearExpiration).toBe(true); + }); + + it('returns one result per key', async () => { + const currentLedger = 1000; + const liveUntil = 2000; + + const k1 = makeKey(); + const k2 = buildContractDataKey(CONTRACT_ID, xdr.ScVal.scvSymbol('balance')); + + const client = { + getLatestLedger: vi.fn().mockResolvedValue({ sequence: currentLedger }), + getLedgerEntries: vi.fn().mockResolvedValue({ + entries: [{ key: k1, xdr: {}, liveUntilLedgerSeq: liveUntil }], + latestLedger: currentLedger, + }), + }; + + const results = await getLedgerEntryTtl([k1, k2], {}, client); + + expect(results).toHaveLength(2); + expect(results[0].liveUntilLedger).toBe(liveUntil); + expect(results[1].liveUntilLedger).toBeNull(); + }); +}); + +// ── buildTtlExtensionTransaction ────────────────────────────────────────────── + +describe('buildTtlExtensionTransaction', () => { + it('returns a non-empty XDR string', async () => { + const client = makeTxClient(); + const key = makeKey(); + + const xdrStr = await buildTtlExtensionTransaction([key], SOURCE_KEY, {}, client); + + expect(typeof xdrStr).toBe('string'); + expect(xdrStr.length).toBeGreaterThan(0); + }); + + it('calls prepareTransaction once', async () => { + const client = makeTxClient(); + const key = makeKey(); + + await buildTtlExtensionTransaction([key], SOURCE_KEY, {}, client); + + expect(client.prepareTransaction).toHaveBeenCalledOnce(); + }); + + it('fetches the source account', async () => { + const client = makeTxClient(); + const key = makeKey(); + + await buildTtlExtensionTransaction([key], SOURCE_KEY, {}, client); + + expect(client.getAccount).toHaveBeenCalledWith(SOURCE_KEY); + }); +}); + +// ── checkContractTtl ────────────────────────────────────────────────────────── + +describe('checkContractTtl', () => { + it('returns ok:true with healthy TTL and no extension tx', async () => { + const currentLedger = 1000; + const liveUntil = 5000; + const ttlClient = makeTtlClient(liveUntil, currentLedger); + + const result = await checkContractTtl(CONTRACT_ID, SOURCE_KEY, {}, ttlClient); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.status.extensionTxXdr).toBeNull(); + expect(result.status.instanceTtl.isNearExpiration).toBe(false); + } + }); + + it('builds an extension tx when entry is near expiration', async () => { + const currentLedger = 1000; + const liveUntil = 1000 + DEFAULT_WARNING_LEDGERS - 1; + const ttlClient = makeTtlClient(liveUntil, currentLedger); + const txClient = makeTxClient(); + + const result = await checkContractTtl(CONTRACT_ID, SOURCE_KEY, {}, ttlClient, txClient); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.status.extensionTxXdr).toBe('prepared-tx-xdr'); + expect(result.status.instanceTtl.isNearExpiration).toBe(true); + } + }); + + it('builds an extension tx when entry is already expired', async () => { + const currentLedger = 2000; + const liveUntil = 1999; + const ttlClient = makeTtlClient(liveUntil, currentLedger); + const txClient = makeTxClient(); + + const result = await checkContractTtl(CONTRACT_ID, SOURCE_KEY, {}, ttlClient, txClient); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.status.extensionTxXdr).not.toBeNull(); + expect(result.status.instanceTtl.isExpired).toBe(true); + } + }); + + it('returns ok:false when the RPC call fails', async () => { + const ttlClient = { + getLatestLedger: vi.fn().mockRejectedValue(new Error('ECONNREFUSED')), + getLedgerEntries: vi.fn(), + }; + + const result = await checkContractTtl(CONTRACT_ID, SOURCE_KEY, {}, ttlClient); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(typeof result.error).toBe('string'); + expect(result.error.length).toBeGreaterThan(0); + } + }); + + it('includes the contractId in the status', async () => { + const ttlClient = makeTtlClient(5000, 1000); + + const result = await checkContractTtl(CONTRACT_ID, SOURCE_KEY, {}, ttlClient); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.status.contractId).toBe(CONTRACT_ID); + } + }); +}); + +// ── Constants ───────────────────────────────────────────────────────────────── + +describe('TTL constants', () => { + it('DEFAULT_WARNING_LEDGERS is positive and meaningful', () => { + expect(DEFAULT_WARNING_LEDGERS).toBeGreaterThan(0); + }); + + it('DEFAULT_EXTEND_TO_LEDGERS exceeds DEFAULT_WARNING_LEDGERS', () => { + expect(DEFAULT_EXTEND_TO_LEDGERS).toBeGreaterThan(DEFAULT_WARNING_LEDGERS); + }); +}); diff --git a/packages/stellar/src/soroban-ttl-manager.ts b/packages/stellar/src/soroban-ttl-manager.ts new file mode 100644 index 00000000..4ac8f8d5 --- /dev/null +++ b/packages/stellar/src/soroban-ttl-manager.ts @@ -0,0 +1,272 @@ +/** + * Soroban Contract Ledger Entry TTL Management (Issue #092) + * + * Tracks the time-to-live (TTL) of persistent Soroban ledger entries and + * automatically builds TTL extension transactions before they expire. + * + * ## Background + * Soroban persistent storage entries carry a `liveUntilLedgerSeq` value. + * Once `currentLedger > liveUntilLedgerSeq` the entry is archived and no + * longer accessible without a restore operation. With ~5 s per ledger, + * 1 000 remaining ledgers is approximately 83 minutes of runway. + * + * ## Flow + * 1. Call `getLedgerEntryTtl` with a list of ledger keys. + * 2. Inspect `isNearExpiration` / `isExpired` on each result. + * 3. Pass at-risk keys to `buildTtlExtensionTransaction` to obtain a + * prepared, unsigned transaction that extends their TTL. + * 4. Sign and submit the prepared transaction via `sendSorobanTransaction`. + * + * ## High-level helper + * `checkContractTtl(contractId)` wraps steps 1–3 for the common case of + * managing a contract's own instance entry. + * + * @see https://developers.stellar.org/docs/smart-contracts/storage-and-ttl + */ + +import { + SorobanRpc, + xdr, + Contract, + TransactionBuilder, + Operation, + Networks, + BASE_FEE, + SorobanDataBuilder, +} from 'stellar-sdk'; +import { config } from './config'; +import { parseStellarError } from './errors'; + +// ── Defaults ────────────────────────────────────────────────────────────────── + +/** + * Warn when fewer than this many ledgers remain on an entry. + * ~1 000 ledgers ≈ 83 minutes at 5 s/ledger. + */ +export const DEFAULT_WARNING_LEDGERS = 1_000; + +/** + * Target live-until value expressed as ledgers **from now** when extending TTL. + * ~172 800 ledgers ≈ 1 week at 5 s/ledger. + */ +export const DEFAULT_EXTEND_TO_LEDGERS = 172_800; + +// ── Public types ────────────────────────────────────────────────────────────── + +export interface TtlThresholds { + /** Remaining-ledger count below which an entry is considered near expiration. */ + warningLedgers?: number; + /** How many ledgers from now to extend an at-risk entry's TTL to. */ + extendToLedgers?: number; +} + +export interface LedgerEntryTtlInfo { + key: xdr.LedgerKey; + /** Ledger sequence after which the entry is archived, or null if unavailable. */ + liveUntilLedger: number | null; + /** Sequence number of the latest ledger at query time. */ + currentLedger: number; + /** Remaining ledgers = liveUntilLedger − currentLedger, or null. */ + remainingLedgers: number | null; + /** true when liveUntilLedger <= currentLedger (entry has expired). */ + isExpired: boolean; + /** true when remainingLedgers <= warningLedgers (entry is at risk). */ + isNearExpiration: boolean; +} + +export interface ContractTtlStatus { + contractId: string; + /** TTL info for the contract's own instance entry. */ + instanceTtl: LedgerEntryTtlInfo; + /** Prepared unsigned transaction XDR for TTL extension, or null when not needed. */ + extensionTxXdr: string | null; +} + +export type TtlCheckResult = + | { ok: true; status: ContractTtlStatus } + | { ok: false; error: string }; + +// ── Ledger key helpers ──────────────────────────────────────────────────────── + +/** + * Build the `LedgerKey` for a Soroban contract's own instance entry. + * This is the key under which the contract's WASM hash and storage are held. + * + * @param contractId - The contract address (C...) + */ +export function buildContractInstanceKey(contractId: string): xdr.LedgerKey { + return new Contract(contractId).getFootprint(); +} + +/** + * Build a `LedgerKey` for a named persistent contract data entry. + * + * @param contractId - The contract address (C...) + * @param storageKey - The `ScVal` used as the storage key inside the contract + */ +export function buildContractDataKey(contractId: string, storageKey: xdr.ScVal): xdr.LedgerKey { + const contract = new Contract(contractId); + return xdr.LedgerKey.contractData( + new xdr.LedgerKeyContractData({ + contract: contract.address().toScAddress(), + key: storageKey, + durability: xdr.ContractDataDurability.persistent(), + }), + ); +} + +// ── TTL query ───────────────────────────────────────────────────────────────── + +/** + * Query the current TTL for each ledger key and classify entries as + * near-expiration, expired, or healthy. + * + * @param keys - Ledger keys to inspect + * @param thresholds - Optional TTL classification thresholds + * @param client - Optional Soroban RPC client override (for testing) + * @returns One `LedgerEntryTtlInfo` per key in the same order + */ +export async function getLedgerEntryTtl( + keys: xdr.LedgerKey[], + thresholds: TtlThresholds = {}, + client: Pick = new SorobanRpc.Server( + getSorobanRpcUrl(), + { allowHttp: false }, + ), +): Promise { + const warningLedgers = thresholds.warningLedgers ?? DEFAULT_WARNING_LEDGERS; + + const [latestLedger, entriesResponse] = await Promise.all([ + client.getLatestLedger(), + client.getLedgerEntries(...keys), + ]); + + const currentLedger = latestLedger.sequence; + + return keys.map((key) => { + const entry = entriesResponse.entries.find( + (e) => e.key.toXDR('base64') === key.toXDR('base64'), + ); + + const liveUntilLedger = entry?.liveUntilLedgerSeq ?? null; + const remainingLedgers = + liveUntilLedger !== null ? liveUntilLedger - currentLedger : null; + const isExpired = liveUntilLedger !== null && liveUntilLedger <= currentLedger; + const isNearExpiration = + !isExpired && remainingLedgers !== null && remainingLedgers < warningLedgers; + + return { key, liveUntilLedger, currentLedger, remainingLedgers, isExpired, isNearExpiration }; + }); +} + +// ── TTL extension transaction builder ──────────────────────────────────────── + +/** + * Build an unsigned `ExtendFootprintTtl` transaction for the given keys. + * + * The transaction must be signed and submitted by the caller. Simulate it + * first via `SorobanRpc.Server.prepareTransaction` before signing. + * + * @param keys - Ledger keys whose TTL should be extended + * @param sourcePublicKey - Account that will sign and pay fees + * @param thresholds - Optional TTL thresholds (defaults used when omitted) + * @param client - Optional Soroban RPC client override (for testing) + * @returns Base64 XDR of the prepared unsigned transaction + */ +export async function buildTtlExtensionTransaction( + keys: xdr.LedgerKey[], + sourcePublicKey: string, + thresholds: TtlThresholds = {}, + client: Pick = new SorobanRpc.Server( + getSorobanRpcUrl(), + { allowHttp: false }, + ), +): Promise { + const extendToLedgers = thresholds.extendToLedgers ?? DEFAULT_EXTEND_TO_LEDGERS; + + const account = await client.getAccount(sourcePublicKey); + + const sorobanData = new SorobanDataBuilder() + .setReadOnly(keys) + .build(); + + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: getNetworkPassphrase(), + }) + .addOperation(Operation.extendFootprintTtl({ extendTo: extendToLedgers })) + .setSorobanData(sorobanData) + .setTimeout(30) + .build(); + + const prepared = await client.prepareTransaction(tx); + return prepared.toXDR(); +} + +// ── High-level contract TTL check ──────────────────────────────────────────── + +/** + * Check whether a contract's instance entry is approaching expiration and, + * if so, build a TTL extension transaction automatically. + * + * @param contractId - The contract address (C...) + * @param sourcePublicKey - Account to use when building the extension transaction + * @param thresholds - Optional TTL classification and extension thresholds + * @param ttlClient - Optional client override for TTL queries (for testing) + * @param txClient - Optional client override for transaction building (for testing) + * @returns `{ ok: true, status }` on success, `{ ok: false, error }` on failure + * + * @example + * ```typescript + * const result = await checkContractTtl(contractId, operatorKey); + * if (result.ok && result.status.extensionTxXdr) { + * const signedXdr = await walletSign(result.status.extensionTxXdr); + * await sendSorobanTransaction(signedXdr); + * } + * ``` + */ +export async function checkContractTtl( + contractId: string, + sourcePublicKey: string, + thresholds: TtlThresholds = {}, + ttlClient?: Parameters[2], + txClient?: Parameters[3], +): Promise { + try { + const instanceKey = buildContractInstanceKey(contractId); + const [instanceTtl] = await getLedgerEntryTtl([instanceKey], thresholds, ttlClient); + + let extensionTxXdr: string | null = null; + if (instanceTtl.isNearExpiration || instanceTtl.isExpired) { + extensionTxXdr = await buildTtlExtensionTransaction( + [instanceKey], + sourcePublicKey, + thresholds, + txClient, + ); + } + + return { ok: true, status: { contractId, instanceTtl, extensionTxXdr } }; + } catch (error: unknown) { + const parsed = parseStellarError(error); + return { ok: false, error: parsed.message }; + } +} + +// ── Internal helpers ────────────────────────────────────────────────────────── + +const SOROBAN_RPC_URLS = { + mainnet: 'https://soroban-mainnet.stellar.org', + testnet: 'https://soroban-testnet.stellar.org', +} as const; + +function getSorobanRpcUrl(): string { + return ( + process.env.NEXT_PUBLIC_SOROBAN_RPC_URL || + SOROBAN_RPC_URLS[config.stellar.network] + ); +} + +function getNetworkPassphrase(): string { + return config.stellar.network === 'mainnet' ? Networks.PUBLIC : Networks.TESTNET; +} diff --git a/packages/stellar/src/soroban-xdr-deserializer.test.ts b/packages/stellar/src/soroban-xdr-deserializer.test.ts new file mode 100644 index 00000000..f5010f5d --- /dev/null +++ b/packages/stellar/src/soroban-xdr-deserializer.test.ts @@ -0,0 +1,276 @@ +import { describe, it, expect } from 'vitest'; +import { xdr, StrKey } from 'stellar-sdk'; +import { deserializeScVal, deserializeScValAs, SorobanDeserializationError } from './soroban-xdr-deserializer'; + +// ── Scalar types ────────────────────────────────────────────────────────────── + +describe('scvBool', () => { + it('deserializes true', () => { + expect(deserializeScVal(xdr.ScVal.scvBool(true))).toBe(true); + }); + + it('deserializes false', () => { + expect(deserializeScVal(xdr.ScVal.scvBool(false))).toBe(false); + }); +}); + +describe('scvVoid', () => { + it('deserializes to null', () => { + expect(deserializeScVal(xdr.ScVal.scvVoid())).toBeNull(); + }); +}); + +describe('scvU32 / scvI32', () => { + it('deserializes u32', () => { + expect(deserializeScVal(xdr.ScVal.scvU32(42))).toBe(42); + }); + + it('deserializes i32 (positive)', () => { + expect(deserializeScVal(xdr.ScVal.scvI32(100))).toBe(100); + }); + + it('deserializes i32 (negative)', () => { + expect(deserializeScVal(xdr.ScVal.scvI32(-7))).toBe(-7); + }); +}); + +describe('scvU64 / scvI64', () => { + it('deserializes u64 to bigint', () => { + const val = xdr.ScVal.scvU64(new xdr.Uint64(9_007_199_254_740_993n)); + expect(deserializeScVal(val)).toBe(9_007_199_254_740_993n); + }); + + it('deserializes i64 (positive) to bigint', () => { + const val = xdr.ScVal.scvI64(new xdr.Int64(1_000_000_000n)); + expect(deserializeScVal(val)).toBe(1_000_000_000n); + }); + + it('deserializes i64 (negative) to bigint', () => { + const val = xdr.ScVal.scvI64(new xdr.Int64(-1n)); + expect(deserializeScVal(val)).toBe(-1n); + }); + + it('deserializes i64 MIN_INT64', () => { + const MIN = -9_223_372_036_854_775_808n; + const val = xdr.ScVal.scvI64(new xdr.Int64(MIN)); + expect(deserializeScVal(val)).toBe(MIN); + }); +}); + +describe('scvTimepoint / scvDuration', () => { + it('deserializes timepoint as bigint', () => { + const val = xdr.ScVal.scvTimepoint(new xdr.TimePoint(12345n)); + expect(deserializeScVal(val)).toBe(12345n); + }); + + it('deserializes duration as bigint', () => { + const val = xdr.ScVal.scvDuration(new xdr.Duration(99n)); + expect(deserializeScVal(val)).toBe(99n); + }); +}); + +describe('scvU128 / scvI128', () => { + it('deserializes u128 (small value)', () => { + const val = xdr.ScVal.scvU128( + new xdr.UInt128Parts({ hi: new xdr.Uint64(0n), lo: new xdr.Uint64(255n) }), + ); + expect(deserializeScVal(val)).toBe(255n); + }); + + it('deserializes u128 (value spanning hi and lo)', () => { + // hi=1, lo=0 → 1 * 2^64 + const val = xdr.ScVal.scvU128( + new xdr.UInt128Parts({ hi: new xdr.Uint64(1n), lo: new xdr.Uint64(0n) }), + ); + expect(deserializeScVal(val)).toBe(1n << 64n); + }); + + it('deserializes i128 (positive)', () => { + const val = xdr.ScVal.scvI128( + new xdr.Int128Parts({ hi: new xdr.Int64(0n), lo: new xdr.Uint64(1000n) }), + ); + expect(deserializeScVal(val)).toBe(1000n); + }); + + it('deserializes i128 (negative with sign in hi)', () => { + // -1 in i128: hi = -1 (all ones in signed 64-bit), lo = 2^64 - 1 + const val = xdr.ScVal.scvI128( + new xdr.Int128Parts({ hi: new xdr.Int64(-1n), lo: new xdr.Uint64(0xFFFFFFFFFFFFFFFFn) }), + ); + expect(deserializeScVal(val)).toBe(-1n); + }); +}); + +describe('scvU256 / scvI256', () => { + it('deserializes u256 (small value in loLo)', () => { + const val = xdr.ScVal.scvU256( + new xdr.UInt256Parts({ + hiHi: new xdr.Uint64(0n), + hiLo: new xdr.Uint64(0n), + loHi: new xdr.Uint64(0n), + loLo: new xdr.Uint64(7n), + }), + ); + expect(deserializeScVal(val)).toBe(7n); + }); + + it('deserializes i256 (positive)', () => { + const val = xdr.ScVal.scvI256( + new xdr.Int256Parts({ + hiHi: new xdr.Int64(0n), + hiLo: new xdr.Uint64(0n), + loHi: new xdr.Uint64(0n), + loLo: new xdr.Uint64(42n), + }), + ); + expect(deserializeScVal(val)).toBe(42n); + }); +}); + +// ── String-like types ───────────────────────────────────────────────────────── + +describe('scvBytes', () => { + it('deserializes bytes to Buffer', () => { + const buf = Buffer.from([1, 2, 3]); + const val = xdr.ScVal.scvBytes(buf); + const result = deserializeScVal(val); + expect(Buffer.isBuffer(result)).toBe(true); + expect(result).toEqual(buf); + }); +}); + +describe('scvString', () => { + it('deserializes to string', () => { + expect(deserializeScVal(xdr.ScVal.scvString('hello'))).toBe('hello'); + }); + + it('deserializes empty string', () => { + expect(deserializeScVal(xdr.ScVal.scvString(''))).toBe(''); + }); +}); + +describe('scvSymbol', () => { + it('deserializes to string', () => { + expect(deserializeScVal(xdr.ScVal.scvSymbol('transfer'))).toBe('transfer'); + }); +}); + +// ── Collection types ────────────────────────────────────────────────────────── + +describe('scvVec', () => { + it('deserializes an empty vector', () => { + expect(deserializeScVal(xdr.ScVal.scvVec([]))).toEqual([]); + }); + + it('deserializes a vector of scalars', () => { + const val = xdr.ScVal.scvVec([xdr.ScVal.scvU32(1), xdr.ScVal.scvU32(2), xdr.ScVal.scvU32(3)]); + expect(deserializeScVal(val)).toEqual([1, 2, 3]); + }); + + it('deserializes a nested vector', () => { + const inner = xdr.ScVal.scvVec([xdr.ScVal.scvBool(true)]); + const outer = xdr.ScVal.scvVec([inner, xdr.ScVal.scvVoid()]); + expect(deserializeScVal(outer)).toEqual([[true], null]); + }); +}); + +describe('scvMap', () => { + it('deserializes an empty map', () => { + expect(deserializeScVal(xdr.ScVal.scvMap([]))).toEqual({}); + }); + + it('deserializes a map with symbol keys', () => { + const entry = new xdr.ScMapEntry({ + key: xdr.ScVal.scvSymbol('balance'), + val: xdr.ScVal.scvU32(500), + }); + expect(deserializeScVal(xdr.ScVal.scvMap([entry]))).toEqual({ balance: 500 }); + }); + + it('deserializes a map with string keys', () => { + const entry = new xdr.ScMapEntry({ + key: xdr.ScVal.scvString('name'), + val: xdr.ScVal.scvString('Alice'), + }); + expect(deserializeScVal(xdr.ScVal.scvMap([entry]))).toEqual({ name: 'Alice' }); + }); + + it('deserializes a map with mixed value types', () => { + const entries = [ + new xdr.ScMapEntry({ key: xdr.ScVal.scvSymbol('count'), val: xdr.ScVal.scvU32(3) }), + new xdr.ScMapEntry({ key: xdr.ScVal.scvSymbol('active'), val: xdr.ScVal.scvBool(true) }), + ]; + const result = deserializeScVal(xdr.ScVal.scvMap(entries)) as Record; + expect(result.count).toBe(3); + expect(result.active).toBe(true); + }); +}); + +// ── Address type ────────────────────────────────────────────────────────────── + +describe('scvAddress', () => { + it('deserializes an account address to G... string', () => { + const pubKey = 'GCEZWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCGSNFHEYVXM3XOJMDS674JZ'; + const keyBytes = StrKey.decodeEd25519PublicKey(pubKey); + const addr = xdr.ScAddress.scAddressTypeAccount( + xdr.AccountId.publicKeyTypeEd25519(keyBytes), + ); + const val = xdr.ScVal.scvAddress(addr); + expect(deserializeScVal(val)).toBe(pubKey); + }); + + it('deserializes a contract address to C... string', () => { + const contractId = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM'; + const contractBytes = StrKey.decodeContract(contractId); + const addr = xdr.ScAddress.scAddressTypeContract(contractBytes); + const val = xdr.ScVal.scvAddress(addr); + expect(deserializeScVal(val)).toBe(contractId); + }); +}); + +// ── Error handling ──────────────────────────────────────────────────────────── + +describe('scvError', () => { + it('throws SorobanDeserializationError', () => { + // Build a minimal error ScVal using the available API + const errVal = xdr.ScVal.scvError( + xdr.ScError.sceValue(xdr.ScErrorCode.scecArithDomain()), + ); + expect(() => deserializeScVal(errVal)).toThrow(SorobanDeserializationError); + }); + + it('error has scvType set to scvError', () => { + const errVal = xdr.ScVal.scvError( + xdr.ScError.sceValue(xdr.ScErrorCode.scecArithDomain()), + ); + try { + deserializeScVal(errVal); + expect.fail('should have thrown'); + } catch (e) { + expect(e).toBeInstanceOf(SorobanDeserializationError); + expect((e as SorobanDeserializationError).scvType).toBe('scvError'); + } + }); +}); + +// ── deserializeScValAs ──────────────────────────────────────────────────────── + +describe('deserializeScValAs', () => { + it('returns typed value when guard passes', () => { + const val = xdr.ScVal.scvU32(99); + const result = deserializeScValAs(val, (v): v is number => typeof v === 'number'); + expect(result).toBe(99); + }); + + it('throws when guard fails', () => { + const val = xdr.ScVal.scvU32(99); + expect(() => + deserializeScValAs(val, (v): v is string => typeof v === 'string'), + ).toThrow(SorobanDeserializationError); + }); + + it('works without guard (plain type cast)', () => { + const val = xdr.ScVal.scvBool(false); + expect(deserializeScValAs(val)).toBe(false); + }); +}); diff --git a/packages/stellar/src/soroban-xdr-deserializer.ts b/packages/stellar/src/soroban-xdr-deserializer.ts new file mode 100644 index 00000000..a6990981 --- /dev/null +++ b/packages/stellar/src/soroban-xdr-deserializer.ts @@ -0,0 +1,270 @@ +/** + * Type-Safe XDR Deserialization for Soroban Contract Return Values (Issue #090) + * + * Converts raw `xdr.ScVal` objects returned by Soroban contract invocations + * into strongly-typed TypeScript values, eliminating manual XDR parsing. + * + * ## Type mapping + * | ScVal type | TypeScript type | + * |--------------------------|------------------------------| + * | scvBool | boolean | + * | scvVoid | null | + * | scvU32 / scvI32 | number | + * | scvU64 / scvI64 | bigint | + * | scvTimepoint / scvDuration | bigint | + * | scvU128 / scvI128 | bigint | + * | scvU256 / scvI256 | bigint | + * | scvBytes | Buffer | + * | scvString / scvSymbol | string | + * | scvVec | SorobanValue[] | + * | scvMap | Record | + * | scvAddress | string (G... or C... address)| + * | scvError | throws SorobanDeserializationError | + * + * Malformed or unknown ScVal types always throw `SorobanDeserializationError`. + * Values are never silently coerced. + */ + +import { xdr, StrKey } from 'stellar-sdk'; + +// ── Public types ────────────────────────────────────────────────────────────── + +/** + * Union of all possible deserialized Soroban values. + * Recursive through `SorobanValue[]` and `Record`. + */ +export type SorobanValue = + | boolean + | null + | number + | bigint + | string + | Buffer + | SorobanValue[] + | { [key: string]: SorobanValue }; + +/** + * Thrown when an `xdr.ScVal` cannot be safely deserialized. + * The `scvType` property holds the raw discriminant name for diagnostics. + */ +export class SorobanDeserializationError extends Error { + constructor( + message: string, + public readonly scvType?: string, + ) { + super(message); + this.name = 'SorobanDeserializationError'; + } +} + +// ── Main entry point ────────────────────────────────────────────────────────── + +/** + * Deserialize a Soroban `xdr.ScVal` into a strongly-typed `SorobanValue`. + * + * @param scVal - The raw XDR value returned by a contract invocation + * @returns The deserialized TypeScript value + * @throws {SorobanDeserializationError} if the value is an error type or + * the discriminant is not a recognized ScVal variant + * + * @example + * ```typescript + * const sim = await simulateContractCall(contractId, 'get_balance', args, key); + * const retval = (sim as SimulateTransactionSuccessResponse).result?.retval; + * const balance = deserializeScVal(retval) as bigint; // scvI128 + * ``` + */ +export function deserializeScVal(scVal: xdr.ScVal): SorobanValue { + const typeName = scVal.switch().name as string; + + switch (typeName) { + case 'scvBool': + return scVal.b(); + + case 'scvVoid': + return null; + + case 'scvError': { + const err = scVal.error(); + const typeStr = err.switch().name ?? 'unknown'; + throw new SorobanDeserializationError( + `Contract returned an error value (type: ${typeStr})`, + typeName, + ); + } + + case 'scvU32': + return scVal.u32(); + + case 'scvI32': + return scVal.i32(); + + case 'scvU64': + return uint64ToBigInt(scVal.u64()); + + case 'scvI64': + return int64ToBigInt(scVal.i64()); + + case 'scvTimepoint': + return uint64ToBigInt(scVal.timepoint()); + + case 'scvDuration': + return uint64ToBigInt(scVal.duration()); + + case 'scvU128': { + const p = scVal.u128(); + return (uint64ToBigInt(p.hi()) << 64n) | uint64ToBigInt(p.lo()); + } + + case 'scvI128': { + const p = scVal.i128(); + // hi is signed (Int64), lo is unsigned (Uint64) + const hi = int64ToBigInt(p.hi()); + const lo = uint64ToBigInt(p.lo()); + return (hi << 64n) | lo; + } + + case 'scvU256': { + const p = scVal.u256(); + return ( + (uint64ToBigInt(p.hiHi()) << 192n) | + (uint64ToBigInt(p.hiLo()) << 128n) | + (uint64ToBigInt(p.loHi()) << 64n) | + uint64ToBigInt(p.loLo()) + ); + } + + case 'scvI256': { + const p = scVal.i256(); + // hiHi is signed (Int64); the remaining three are unsigned + const hiHi = int64ToBigInt(p.hiHi()); + const hiLo = uint64ToBigInt(p.hiLo()); + const loHi = uint64ToBigInt(p.loHi()); + const loLo = uint64ToBigInt(p.loLo()); + return (hiHi << 192n) | (hiLo << 128n) | (loHi << 64n) | loLo; + } + + case 'scvBytes': + return scVal.bytes(); + + case 'scvString': + return scVal.str().toString(); + + case 'scvSymbol': + return scVal.sym().toString(); + + case 'scvVec': { + const items = scVal.vec(); + if (items === undefined) return []; + return items.map((item) => deserializeScVal(item)); + } + + case 'scvMap': { + const entries = scVal.map(); + if (entries === undefined) return {}; + const result: { [key: string]: SorobanValue } = {}; + for (const entry of entries) { + const key = mapKeyToString(entry.key()); + result[key] = deserializeScVal(entry.val()); + } + return result; + } + + case 'scvAddress': + return decodeAddress(scVal.address()); + + case 'scvLedgerKeyContractInstance': + return null; + + case 'scvLedgerKeyNonce': { + const nonce = scVal.nonce(); + return int64ToBigInt(nonce.nonce()); + } + + case 'scvContractInstance': + return null; + + default: + throw new SorobanDeserializationError( + `Unrecognized ScVal type: ${typeName}`, + typeName, + ); + } +} + +// ── Type-parameterised helper ───────────────────────────────────────────────── + +/** + * Deserialize a ScVal and cast to the expected TypeScript type `T`. + * + * Throws `SorobanDeserializationError` when the deserialized value does not + * satisfy the optional `guard` predicate. + * + * @example + * ```typescript + * const count = deserializeScValAs(retval, (v): v is number => typeof v === 'number'); + * ``` + */ +export function deserializeScValAs( + scVal: xdr.ScVal, + guard?: (v: SorobanValue) => v is T, +): T { + const value = deserializeScVal(scVal); + if (guard && !guard(value)) { + throw new SorobanDeserializationError( + `Deserialized value did not match expected type (got ${typeof value})`, + scVal.switch().name, + ); + } + return value as T; +} + +// ── Internal helpers ────────────────────────────────────────────────────────── + +/** Unsigned 64-bit XDR integer → BigInt. */ +function uint64ToBigInt(v: { high: number; low: number }): bigint { + return (BigInt(v.high >>> 0) << 32n) | BigInt(v.low >>> 0); +} + +/** Signed 64-bit XDR integer → BigInt (preserves two's-complement sign). */ +function int64ToBigInt(v: { high: number; low: number }): bigint { + const unsigned = (BigInt(v.high >>> 0) << 32n) | BigInt(v.low >>> 0); + // If the sign bit (bit 63) is set, convert from unsigned to signed representation. + return v.high < 0 ? unsigned - (1n << 64n) : unsigned; +} + +/** Convert a ScAddress to its Stellar StrKey string representation. */ +function decodeAddress(addr: xdr.ScAddress): string { + const typeName = addr.switch().name as string; + if (typeName === 'scAddressTypeAccount') { + return StrKey.encodeEd25519PublicKey(addr.accountId().ed25519()); + } + if (typeName === 'scAddressTypeContract') { + return StrKey.encodeContract(addr.contractId()); + } + throw new SorobanDeserializationError( + `Unknown ScAddress type: ${typeName}`, + 'scvAddress', + ); +} + +/** + * Convert a ScVal map key to a string for use as an object property name. + * Symbols and strings are used verbatim; numeric and other types are + * rendered as their string representation. + */ +function mapKeyToString(key: xdr.ScVal): string { + const typeName = key.switch().name as string; + switch (typeName) { + case 'scvSymbol': return key.sym().toString(); + case 'scvString': return key.str().toString(); + case 'scvU32': return String(key.u32()); + case 'scvI32': return String(key.i32()); + case 'scvU64': return uint64ToBigInt(key.u64()).toString(); + case 'scvI64': return int64ToBigInt(key.i64()).toString(); + case 'scvBool': return String(key.b()); + default: + // Fall back to a recognisable placeholder rather than silently losing data. + return `[${typeName}]`; + } +}