diff --git a/docs/contributing.md b/docs/contributing.md index 1c952310..2c0d4f38 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -58,6 +58,18 @@ docs/ └── contributing.md # This file ``` +### Stellar library modules + +The stable Stellar API is exported from `src/lib/stellar.ts`, which forwards to +the domain barrel at `src/lib/stellar/index.ts`. Existing imports from +`src/lib/stellar` remain supported. New internal code may import a focused +module such as `src/lib/stellar/networks.ts`, `horizon.ts`, `addresses.ts`, +`soroban.ts`, `pricing.ts`, `claimableBalances.ts`, or `reserves.ts` when it +needs only that domain. Prefer `getServer`; the `ee` alias is deprecated and +retained temporarily for compatibility. Custom-network auth headers continue +to be stored in session storage (not local storage); do not put wallet secrets +or signing keys in network configuration. + --- ## Coding Conventions diff --git a/eslint-rules/no-direct-submit.mjs b/eslint-rules/no-direct-submit.mjs index 4e694fb3..b08dc066 100644 --- a/eslint-rules/no-direct-submit.mjs +++ b/eslint-rules/no-direct-submit.mjs @@ -39,6 +39,7 @@ const noDirectSubmit = { 'src/lib/contractInvoker', 'src/lib/horizonRetry', 'src/lib/bulkOperations', // batch layer, calls signAndSubmitTransaction internally + 'src/lib/stellar/soroban', ]; const filename = context.getFilename().replace(/\\/g, '/'); diff --git a/src/lib/stellar.ts b/src/lib/stellar.ts index 7aaa9f70..99d032f1 100644 --- a/src/lib/stellar.ts +++ b/src/lib/stellar.ts @@ -1,3509 +1,2 @@ -import * as StellarSdk from '@stellar/stellar-sdk'; -import { Cache, TTL } from './cache.js'; -import { rateLimiter } from './rateLimiter.js'; -import auditTrail from './auditTrail.js'; -import { getCircuitBreaker } from './errorHandling/CircuitBreaker'; -import { validateMemo } from './validation'; -import { requireAllowedEndpoint } from './endpointAllowlist'; -import { importBatchXdr, simulateBatchXdr, validateXdrForBroadcast, type BatchXdrImportResult, type BatchXdrImportOptions, type ValidationReportItem, type XdrImportItem } from './batchXdrImport' - -// ─── Cache setup ────────────────────────────────────────────────────────────── - -const stellarCache = new Cache({ - namespace: 'stellar', - persist: true, - maxSize: 500, - defaultTTL: TTL.ACCOUNT, -}); - -export { stellarCache }; - -const simulationCache = new Cache({ - namespace: 'simulation', - maxSize: 200, - defaultTTL: TTL.SHORT, -}); - -function buildSimulationCacheKey(params: BuildTransactionParams) { - return simulationCache.generateKey('simulate', { - sourceAccount: params.sourceAccount, - operations: params.operations, - memo: params.memo, - baseFee: params.baseFee, - timeBounds: params.timeBounds, - preconditions: params.preconditions, - network: params.network, - }); -} - -function validateSimulationParams(params: BuildTransactionParams) { - const errors: string[] = []; - const warnings: string[] = []; - - if (!params.sourceAccount || !isValidPublicKey(params.sourceAccount)) { - errors.push('Source account is required and must be a valid Stellar public key.'); - } - - if (!Array.isArray(params.operations) || params.operations.length === 0) { - errors.push('Transaction must include at least one operation.'); - } - - if (!Number.isFinite(params.baseFee) || params.baseFee <= 0) { - errors.push('Base fee must be a positive number.'); - } - - if (params.timeBounds?.minTime && params.timeBounds?.maxTime) { - const minTime = Number(params.timeBounds.minTime); - const maxTime = Number(params.timeBounds.maxTime); - if (Number.isNaN(minTime) || Number.isNaN(maxTime)) { - errors.push('Time bounds must be valid Unix timestamps.'); - } else if (minTime > maxTime) { - errors.push('Min time cannot be greater than max time.'); - } - } - - if (params.preconditions) { - if (params.preconditions.ledgerBounds) { - const minLedger = Number(params.preconditions.ledgerBounds.minLedger); - const maxLedger = Number(params.preconditions.ledgerBounds.maxLedger); - if (!Number.isNaN(minLedger) && minLedger < 0) { - errors.push('Ledger bounds minLedger cannot be negative.'); - } - if (!Number.isNaN(maxLedger) && maxLedger < 0) { - errors.push('Ledger bounds maxLedger cannot be negative.'); - } - if (!Number.isNaN(minLedger) && !Number.isNaN(maxLedger) && maxLedger > 0 && minLedger > maxLedger) { - errors.push('Ledger bounds minLedger cannot be greater than maxLedger.'); - } - } - - if (params.preconditions.minSequence !== undefined && params.preconditions.minSequence !== '') { - const minSeq = Number(params.preconditions.minSequence); - if (Number.isNaN(minSeq) || minSeq < 0) { - errors.push('Min sequence cannot be negative.'); - } - } - - if (params.preconditions.minSequenceAge !== undefined && params.preconditions.minSequenceAge !== '') { - const age = Number(params.preconditions.minSequenceAge); - if (Number.isNaN(age) || age < 0) { - errors.push('Min sequence age cannot be negative.'); - } - } - - if (params.preconditions.minSequenceLedgerGap !== undefined && params.preconditions.minSequenceLedgerGap !== '') { - const gap = Number(params.preconditions.minSequenceLedgerGap); - if (Number.isNaN(gap) || gap < 0) { - errors.push('Min sequence ledger gap cannot be negative.'); - } - } - - if (params.preconditions.extraSigners) { - const invalid = params.preconditions.extraSigners.filter((s) => !isValidPublicKey(s)); - if (invalid.length > 0) { - errors.push(`Invalid extra signer public key(s): ${invalid.join(', ')}`); - } - } - } - - if (typeof params.memo === 'string' && params.memo.length > 28) { - warnings.push('Memo text may exceed the 28-character limit accepted by the Stellar network.'); - } - - params.operations?.forEach((op, index) => { - if (op.type === 'payment') { - if (!isValidPublicKey(op.destination)) { - errors.push(`Operation ${index + 1}: Invalid destination address.`); - } - if (!op.amount || parseFloat(String(op.amount)) <= 0) { - errors.push(`Operation ${index + 1}: Amount must be greater than zero.`); - } - } else if (op.type === 'createAccount') { - if (!isValidPublicKey(op.destination)) { - errors.push(`Operation ${index + 1}: Invalid destination address.`); - } - if (!op.startingBalance || parseFloat(String(op.startingBalance)) < 1) { - errors.push(`Operation ${index + 1}: Starting balance must be at least 1 XLM.`); - } - } else if (op.type === 'invokeHostFunction') { - if (!op.func) { - errors.push(`Operation ${index + 1}: Missing host function payload.`); - } - } - }); - - return { errors, warnings }; -} - -export function getSimulationFeeOptions( - baseFee: number, - operationCount: number, - congestion = 0.55 -) { - const optimized = optimizeTransactionFee(baseFee, operationCount, congestion); - - return [ - { - label: 'Slow / Cost Saver', - fee: Math.max(100, Math.floor(optimized * 0.85)), - expectedInclusion: 'slow', - }, - { - label: 'Standard', - fee: optimized, - expectedInclusion: 'standard', - }, - { - label: 'Priority', - fee: Math.ceil(optimized * 1.2), - expectedInclusion: 'priority', - }, - ]; -} - -// ─── Network config ─────────────────────────────────────────────────────────── - -export type NetworkName = 'mainnet' | 'testnet' | 'futurenet' | 'local' | 'custom'; - -export type { - NetworkCapabilities, - StellarReadSource, - NormalizedLedger, - NormalizedTransaction, - NormalizedEvent, - NormalizedOffer, - GetLedgersParams, - GetLedgersResult, - GetTransactionsParams, - GetTransactionsResult, - GetEventsParams, - GetEventsResult, - GetOffersParams, - GetOffersResult, -} from './stellar/types'; -export { UnsupportedCapabilityError, RetentionWindowExceededError } from './stellar/types'; -export { HorizonReadSource } from './stellar/horizonReadSource'; -export { RpcReadSource } from './stellar/rpcReadSource'; -export { RpcFirstReadSource } from './stellar/rpcFirstReadSource'; -export { getStellarReadSource, evaluateReadSourceCapabilities } from './stellar/index'; - -export interface NetworkConfig { - name: string; - horizonUrl: string; - sorobanUrl?: string; - passphrase: string; - faucetUrl?: string; - customHeaders?: Record; - headers?: Record; - capabilities?: import('./stellar/types').NetworkCapabilities; -} - -export const NETWORKS: Record = { - mainnet: { - name: 'Mainnet', - horizonUrl: 'https://horizon.stellar.org', - sorobanUrl: 'https://soroban-rpc.stellar.org', - passphrase: StellarSdk.Networks.PUBLIC, - capabilities: { - ledgers: true, - transactions: true, - events: true, - accountOffers: true, - fullHistory: true, - defaultReadSource: 'rpc', - }, - }, - testnet: { - name: 'Testnet', - horizonUrl: 'https://horizon-testnet.stellar.org', - sorobanUrl: 'https://soroban-testnet.stellar.org', - passphrase: StellarSdk.Networks.TESTNET, - faucetUrl: 'https://friendbot.stellar.org', - capabilities: { - ledgers: true, - transactions: true, - events: true, - accountOffers: true, - fullHistory: true, - defaultReadSource: 'rpc', - }, - }, - futurenet: { - name: 'Futurenet', - horizonUrl: 'https://horizon-futurenet.stellar.org', - sorobanUrl: 'https://soroban-futurenet.stellar.org', - passphrase: StellarSdk.Networks.FUTURENET, - faucetUrl: 'https://friendbot-futurenet.stellar.org', - capabilities: { - ledgers: true, - transactions: true, - events: true, - accountOffers: true, - fullHistory: true, - defaultReadSource: 'rpc', - }, - }, - local: { - name: 'Local', - horizonUrl: 'http://localhost:8000', - sorobanUrl: 'http://localhost:8000/soroban/rpc', - passphrase: 'Standalone Network ; February 2017', - capabilities: { - ledgers: true, - transactions: true, - events: true, - accountOffers: true, - fullHistory: true, - defaultReadSource: 'rpc', - }, - }, - custom: { - name: 'Custom', - horizonUrl: '', - sorobanUrl: '', - passphrase: '', - headers: {}, - capabilities: { - ledgers: true, - transactions: true, - events: true, - accountOffers: true, - fullHistory: true, - defaultReadSource: 'rpc', - }, - }, -}; - -const COINGECKO_XLM_PRICE_URL = - 'https://api.coingecko.com/api/v3/simple/price?ids=stellar&vs_currencies=usd'; -const CUSTOM_NETWORK_HEADERS_KEY = 'stellar-custom-network-headers'; - -function endpointShape(url: string): string { - try { - const parsed = new URL(url); - return parsed.pathname || '/'; - } catch { - return 'unknown'; - } -} - -function getSessionStorage(): Storage | null { - if (typeof window === 'undefined') return null; - return window.sessionStorage || null; -} - -function normalizeHeaders(headers: Record = {}): Record { - return Object.entries(headers).reduce>((acc, [name, value]) => { - const trimmedName = String(name || '').trim(); - const trimmedValue = String(value || '').trim(); - if (trimmedName && trimmedValue) { - acc[trimmedName] = trimmedValue; - } - return acc; - }, {}); -} - -export function getCustomNetworkAuthHeaders(): Record { - const storage = getSessionStorage(); - if (!storage) return NETWORKS.custom.headers || {}; - - try { - const raw = storage.getItem(CUSTOM_NETWORK_HEADERS_KEY); - const headers = raw ? normalizeHeaders(JSON.parse(raw)) : {}; - NETWORKS.custom.headers = headers; - return headers; - } catch { - return NETWORKS.custom.headers || {}; - } -} - -function saveCustomNetworkAuthHeaders(headers: Record) { - const normalized = normalizeHeaders(headers); - NETWORKS.custom.headers = normalized; - - const storage = getSessionStorage(); - if (!storage) return; - - if (Object.keys(normalized).length) { - storage.setItem(CUSTOM_NETWORK_HEADERS_KEY, JSON.stringify(normalized)); - } else { - storage.removeItem(CUSTOM_NETWORK_HEADERS_KEY); - } -} - -function getNetworkHeaders(network: NetworkName): Record { - if (network === 'custom') return getCustomNetworkAuthHeaders(); - return NETWORKS[network].headers || {}; -} - -function withNetworkHeaders(options: RequestInit = {}, network: NetworkName): RequestInit { - const headers = getNetworkHeaders(network); - if (!Object.keys(headers).length) return options; - - return { - ...options, - headers: { - ...(options.headers as Record | undefined), - ...headers, - }, - }; -} - -function getServerOptions(network: NetworkName) { - const headers = getNetworkHeaders(network); - return Object.keys(headers).length ? { headers } : undefined; -} - -// ─── Rate Limited Fetch Wrapper ─────────────────────────────────────────────── - -async function rateLimitedFetch( - url: string, - options?: RequestInit, - priority: 'high' | 'medium' | 'low' = 'medium', - extraHeaders?: Record -): Promise { - const startTime = Date.now(); - - // Merge custom network headers (e.g. API keys) without mutating caller options - const mergedOptions: RequestInit = - extraHeaders && Object.keys(extraHeaders).length > 0 - ? { - ...options, - headers: { ...(options?.headers as Record | undefined), ...extraHeaders }, - } - : (options ?? {}); - - try { - // Log the API call (options without secret headers — sanitized by auditTrail) - auditTrail.logAPICall(url, mergedOptions.method || 'GET', mergedOptions, {}); - - // Check rate limits first - const check = rateLimiter.checkRequest('stellar_client', rateLimiter.extractEndpoint(url)); - - if (!check.allowed) { - // Queue the request if rate limited - const response = await rateLimiter.queueRequest( - { url, options: mergedOptions, priority }, - 'stellar_client' - ); - const responseTime = Date.now() - startTime; - - auditTrail.logAPICall(url, mergedOptions.method || 'GET', mergedOptions, { - status: response.status, - responseTime, - queued: true, - }); - - return response; - } - - // Execute request immediately if allowed - const response = await fetch(url, mergedOptions); - const responseTime = Date.now() - startTime; - - auditTrail.logAPICall(url, mergedOptions.method || 'GET', mergedOptions, { - status: response.status, - responseTime, - queued: false, - }); - - return response; - } catch (error) { - auditTrail.logError(error as Error, { url, operation: 'rateLimitedFetch' }); - throw error; - } -} - -// ─── Servers ────────────────────────────────────────────────────────────────── - -export function getNetworkDetails(network: NetworkName): NetworkConfig { - return NETWORKS[network]; -} - -export function updateCustomNetworkConfig(config: Partial) { - const { headers, ...networkConfig } = config; - Object.assign(NETWORKS.custom, networkConfig); - if (headers) saveCustomNetworkAuthHeaders(headers); -} - -/** - * Switch to a custom network profile (Issue #188). - * Updates NETWORKS.custom with profile data and creates new clients. - */ -export async function switchToCustomProfile(profileId: string): Promise { - const { getNetworkProfile } = await import('./userPreferences'); - const profile = await getNetworkProfile(profileId); - - if (!profile) { - throw new Error(`Network profile "${profileId}" not found`); - } - - // Update the custom network config - updateCustomNetworkConfig({ - name: profile.name, - horizonUrl: profile.horizonUrl, - sorobanUrl: profile.sorobanUrl, - passphrase: profile.passphrase, - }); -} - -/** - * Load profiles from storage and return them (Issue #188). - */ -export async function loadCustomNetworkProfiles() { - const { loadNetworkProfiles } = await import('./userPreferences'); - return loadNetworkProfiles(); -} - -export function getServer(network: NetworkName = 'testnet'): StellarSdk.Horizon.Server { - const config = NETWORKS[network]; - return new StellarSdk.Horizon.Server( - config.horizonUrl || NETWORKS.testnet.horizonUrl, - getServerOptions(network) - ); -} - -export const ee = getServer; - -export function getSorobanServer(network: NetworkName = 'testnet'): StellarSdk.SorobanRpc.Server { - const config = NETWORKS[network]; - if (network === 'custom' && !config.sorobanUrl) { - throw new Error('Custom Soroban RPC URL not configured'); - } - return new StellarSdk.rpc.Server( - config.sorobanUrl || NETWORKS.testnet.sorobanUrl!, - getServerOptions(network) - ); -} - -export type ProbeStatus = 'up' | 'degraded' | 'down'; - -export interface ServiceProbeResult { - url: string; - status: ProbeStatus; - latency: number | null; - statusCode?: number; - breakerState: CircuitState; - error?: string; -} - -export interface NetworkProbeResult { - network: NetworkName; - name: string; - horizon: ServiceProbeResult; - soroban: ServiceProbeResult; -} - -const PROBE_TIMEOUT_MS = 10_000; -const PROBE_LATENCY_DEGRADED_MS = 1_200; - -function resolveProbeStatus(response: Response, latency: number): ProbeStatus { - if (response.ok) { - return latency > PROBE_LATENCY_DEGRADED_MS ? 'degraded' : 'up'; - } - if (response.status >= 500) { - return 'down'; - } - return 'degraded'; -} - -async function probeServiceUrl( - network: NetworkName, - url: string, - serviceLabel: 'horizon' | 'soroban' -): Promise { - const serviceName = `${serviceLabel}:${network}`; - const breaker = getCircuitBreaker(serviceName, { - failureThreshold: 4, - timeout: 15_000, - }); - - if (!url) { - return { - url, - status: 'down', - latency: null, - breakerState: breaker.currentState, - error: 'URL unavailable', - }; - } - - const start = Date.now(); - let response: Response | null = null; - - try { - response = await breaker.execute(async () => { - const controller = new AbortController(); - const timeoutId = window.setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS); - try { - const headResponse = await rateLimitedFetch( - url, - { method: 'HEAD', cache: 'no-store', signal: controller.signal }, - 'low' - ); - - if (headResponse.status === 405 || headResponse.status === 501) { - return await rateLimitedFetch( - url, - { method: 'GET', cache: 'no-store', signal: controller.signal }, - 'low' - ); - } - - return headResponse; - } finally { - window.clearTimeout(timeoutId); - } - }); - - const latency = Date.now() - start; - return { - url, - status: resolveProbeStatus(response, latency), - latency, - statusCode: response.status, - breakerState: breaker.currentState, - }; - } catch (error) { - return { - url, - status: 'down', - latency: null, - breakerState: breaker.currentState, - error: String(error), - }; - } -} - -export async function probeAllNetworks(): Promise { - const probeKeys = Object.entries(NETWORKS) as [NetworkName, NetworkConfig][]; - const probes = probeKeys.map(async ([network, config]) => { - const horizon = await probeServiceUrl(network, config.horizonUrl, 'horizon'); - const soroban = await probeServiceUrl(network, config.sorobanUrl || '', 'soroban'); - - return { - network, - name: config.name, - horizon, - soroban, - }; - }); - - return Promise.all(probes); -} - -// ─── Cancellation ───────────────────────────────────────────────────────────── - -/** - * Per-request options accepted by the account-scoped Horizon readers. - * - * `signal` lets a caller abandon a read when the user switches account or network - * (Issue #745). Every reader below is backwards compatible: omit the argument and - * behaviour is unchanged. - */ -export interface HorizonRequestOptions { - signal?: AbortSignal; -} - -/** DOM-compatible abort error, usable where `DOMException` is unavailable. */ -function createAbortError(): Error { - if (typeof DOMException === 'function') { - return new DOMException('The operation was aborted.', 'AbortError'); - } - const error = new Error('The operation was aborted.'); - error.name = 'AbortError'; - return error; -} - -/** Rejects immediately when `signal` is already aborted. */ -function throwIfAborted(signal?: AbortSignal): void { - if (signal?.aborted) throw createAbortError(); -} - -/** - * Rejects as soon as `signal` aborts, even when `promise` cannot itself be - * cancelled. The Stellar SDK's `CallBuilder`/`loadAccount` do not accept an - * `AbortSignal`, so the underlying HTTP request may still complete — but its result - * is detached from the caller and can no longer overwrite current state. - */ -function withAbort(promise: Promise, signal?: AbortSignal): Promise { - if (!signal) return promise; - if (signal.aborted) return Promise.reject(createAbortError()); - - return new Promise((resolve, reject) => { - const onAbort = () => reject(createAbortError()); - signal.addEventListener('abort', onAbort, { once: true }); - promise.then(resolve, reject).finally(() => { - signal.removeEventListener('abort', onAbort); - }); - }); -} - -// ─── Account ────────────────────────────────────────────────────────────────── - -export async function fetchAccount( - publicKey: string, - network: NetworkName = 'testnet', - options: HorizonRequestOptions = {} -): Promise { - throwIfAborted(options.signal); - - const cacheKey = `account:${publicKey}:${network}`; - const cached = stellarCache.get(cacheKey); - if (cached) return cached; - - const breaker = getCircuitBreaker(`horizon:${network}`, { failureThreshold: 5, timeout: 30_000 }); - const server = getServer(network); - const account = await withAbort( - breaker.execute(() => server.loadAccount(publicKey)), - options.signal - ); - stellarCache.set(cacheKey, account, TTL.ACCOUNT, ['account', publicKey]); - return account; -} - -// ─── Transactions & Operations ──────────────────────────────────────────────── - -export async function fetchTransactions( - publicKey: string, - network: NetworkName = 'testnet', - limit = 20, - cursor: string | null = null, - options: HorizonRequestOptions = {} -): Promise<{ - records: StellarSdk.Horizon.ServerApi.TransactionRecord[]; - nextCursor: string | null; - hasMore: boolean; -}> { - throwIfAborted(options.signal); - - const cacheKey = `transactions:${publicKey}:${network}:${limit}:${cursor || 'null'}`; - const cached = stellarCache.get(cacheKey); - if (cached) return cached; - - const server = getServer(network); - const request = server.transactions().forAccount(publicKey).order('desc').limit(limit); - - if (cursor) request.cursor(cursor); - - const txs = await withAbort(request.call(), options.signal); - const records = txs.records || []; - const nextCursor = records.length > 0 ? records[records.length - 1].paging_token : null; - - const result = { - records, - nextCursor, - hasMore: records.length === limit && !!nextCursor, - }; - stellarCache.set(cacheKey, result, TTL.TRANSACTIONS, ['transactions', publicKey]); - return result; -} - -export async function fetchOperations( - publicKey: string, - network: NetworkName = 'testnet', - limit = 20, - cursor: string | null = null, - options: HorizonRequestOptions = {} -): Promise<{ - records: StellarSdk.Horizon.ServerApi.OperationRecord[]; - nextCursor: string | null; - hasMore: boolean; -}> { - throwIfAborted(options.signal); - - const cacheKey = `operations:${publicKey}:${network}:${limit}:${cursor || 'null'}`; - const cached = stellarCache.get(cacheKey); - if (cached) return cached; - - const server = getServer(network); - const request = server.operations().forAccount(publicKey).order('desc').limit(limit); - - if (cursor) request.cursor(cursor); - - const ops = await withAbort(request.call(), options.signal); - const records = ops.records || []; - const nextCursor = records.length > 0 ? records[records.length - 1].paging_token : null; - - const result = { - records, - nextCursor, - hasMore: records.length === limit && !!nextCursor, - }; - stellarCache.set(cacheKey, result, TTL.OPERATIONS, ['operations', publicKey]); - return result; -} - -export async function fetchAccountOffers( - publicKey: string, - network: NetworkName = 'testnet', - options: HorizonRequestOptions = {} -): Promise { - throwIfAborted(options.signal); - - const cacheKey = `offers:${publicKey}:${network}`; - const cached = stellarCache.get(cacheKey); - if (cached) return cached; - - const server = getServer(network); - const offers = await withAbort(server.offers().forAccount(publicKey).call(), options.signal); - const records = offers.records || []; - stellarCache.set(cacheKey, records, TTL.ACCOUNT, ['offers', publicKey]); - return records; -} - -export async function fetchTransactionDetails( - hash: string, - network: NetworkName = 'testnet' -): Promise<{ - transaction: StellarSdk.Horizon.ServerApi.TransactionRecord; - operations: StellarSdk.Horizon.ServerApi.OperationRecord[]; -}> { - const cacheKey = `transaction-details:${hash}:${network}`; - const cached = stellarCache.get(cacheKey); - if (cached) return cached; - - const server = getServer(network); - const [transaction, opsResponse] = await Promise.all([ - server.transactions().transaction(hash).call(), - server.operations().forTransaction(hash).call(), - ]); - - const result = { - transaction, - operations: opsResponse.records || [], - }; - - stellarCache.set(cacheKey, result, TTL.TRANSACTIONS, ['transactions', hash]); - return result; -} - -// ─── Operation labels ─────────────────────────────────────────────────────────── - -export const OPERATION_LABELS: Record = { - create_account: 'Create Account', - payment: 'Payment', - path_payment_strict_send: 'Path Payment (Send)', - path_payment_strict_receive: 'Path Payment (Receive)', - manage_buy_offer: 'Buy Offer', - manage_sell_offer: 'Sell Offer', - create_passive_sell_offer: 'Create Passive Sell Offer', - set_options: 'Set Options', - change_trust: 'Change Trust', - allow_trust: 'Allow Trust', - account_merge: 'Account Merge', - manage_data: 'Manage Data', - bump_sequence: 'Bump Sequence', - create_claimable_balance: 'Create Claimable Balance', - claim_claimable_balance: 'Claim Claimable Balance', - begin_sponsoring_future_reserves: 'Begin Sponsoring Future Reserves', - end_sponsoring_future_reserves: 'End Sponsoring Future Reserves', - revoke_sponsorship: 'Revoke Sponsorship', - clawback: 'Clawback', - clawback_claimable_balance: 'Clawback Claimable Balance', - set_trust_line_flags: 'Set Trustline Flags', - liquidity_pool_deposit: 'Liquidity Pool Deposit', - liquidity_pool_withdraw: 'Liquidity Pool Withdraw', - invoke_host_function: 'Contract Call', - extend_footprint_ttl: 'Extend Footprint TTL', - restore_footprint: 'Restore Footprint', -}; - -function titleCaseLabel(value: string): string { - return value.replace(/_/g, ' ').replace(/\b\w/g, (char) => char.toUpperCase()); -} - -export function getOperationLabel(type: string): string { - return OPERATION_LABELS[type] || titleCaseLabel(type); -} - -export async function fetchAccountCreationDate( - publicKey: string, - network: NetworkName = 'testnet', - options: HorizonRequestOptions = {} -): Promise { - throwIfAborted(options.signal); - - const cacheKey = `creation-date:${publicKey}:${network}`; - const cached = stellarCache.get(cacheKey); - if (cached) return cached; - - const server = getServer(network); - - try { - const ops = await withAbort( - server.operations().forAccount(publicKey).order('asc').limit(1).call(), - options.signal - ); - - const operation = ops.records[0]; - const date = operation?.type === 'create_account' ? operation.created_at || null : null; - - if (date) { - stellarCache.set(cacheKey, date, TTL.ACCOUNT, ['account', publicKey]); - } - return date; - } catch (error) { - // A creation date is best-effort, but an abort is not a "no date found" — - // swallowing it would let a cancelled read resolve and clear current state. - if ((error as Error | undefined)?.name === 'AbortError') throw error; - return null; - } -} - -export function streamLedgers( - callback: (_ledger: StellarSdk.Horizon.ServerApi.LedgerRecord) => void, - network: NetworkName = 'testnet' -): () => void { - const server = getServer(network); - return server - .ledgers() - .cursor('now') - .stream({ - onmessage: (page) => { - if (page?.records?.length) { - page.records.forEach((ledger) => callback(ledger)); - } - }, - onerror: (error) => console.error('Ledger stream error:', error), - }); -} - -// ─── Network stats ──────────────────────────────────────────────────────────── - -export interface NetworkStats { - latestLedger: StellarSdk.Horizon.ServerApi.LedgerRecord; - feeStats: StellarSdk.Horizon.HorizonApi.FeeStatsResponse; -} - -export interface AccountReserves { - baseReserve: number; - signerReserve: number; - assetReserve: number; - offerReserve: number; - subentryReserve: number; - totalReserves: number; - availableBalance: number; - totalBalance: number; -} - -export async function fetchNetworkStats(network: NetworkName = 'testnet'): Promise { - const cacheKey = `network-stats:${network}`; - const cached = stellarCache.get(cacheKey); - if (cached) return cached; - - const breaker = getCircuitBreaker(`horizon:${network}`, { failureThreshold: 5, timeout: 30_000 }); - const server = getServer(network); - const [ledger, feeStats] = await breaker.execute(() => - Promise.all([server.ledgers().order('desc').limit(1).call(), server.feeStats()]) - ); - const result = { - latestLedger: ledger.records[0], - feeStats, - }; - stellarCache.set(cacheKey, result, TTL.LEDGER, ['network-stats', network]); - return result; -} - -/** - * Calculate account reserves based on Stellar network base reserve - * @param accountData - Account response from Horizon - * @param networkStats - Network stats containing ledger with base_reserve - * @param offerCount - Number of open offers (optional, defaults to 0) - * @returns AccountReserves object with breakdown of all reserves - */ -export function calculateAccountReserves( - accountData: StellarSdk.Horizon.AccountResponse, - networkStats: NetworkStats | null, - offerCount: number = 0 -): AccountReserves { - // Get base reserve from ledger (in stroops, convert to XLM) - // Default to 1 XLM if not available (current Stellar default) - const baseReserveStroops = Number(networkStats?.latestLedger?.base_reserve) || 10000000; - const baseReserve = baseReserveStroops / 10000000; // Convert stroops to XLM - - // Count non-native assets (trustlines) - const assetCount = accountData.balances?.filter((b) => b.asset_type !== 'native').length || 0; - - // Count signers (excluding the master key if it's a signer) - const signerCount = - accountData.signers?.filter((s) => s.key !== accountData.account_id).length || 0; - - // Subentry count from account data - const subentryCount = accountData.subentry_count || 0; - - // Calculate reserves (each additional entry costs base_reserve / 2) - const signerReserve = signerCount * (baseReserve / 2); - const assetReserve = assetCount * (baseReserve / 2); - const offerReserve = offerCount * (baseReserve / 2); - const subentryReserve = subentryCount * (baseReserve / 2); - - // Total reserves - const totalReserves = baseReserve + signerReserve + assetReserve + offerReserve + subentryReserve; - - // Get XLM balance - const xlmBalance = accountData.balances?.find((b) => b.asset_type === 'native')?.balance || '0'; - const totalBalance = parseFloat(xlmBalance); - - // Available balance (total - reserves) - const availableBalance = Math.max(0, totalBalance - totalReserves); - - return { - baseReserve, - signerReserve, - assetReserve, - offerReserve, - subentryReserve, - totalReserves, - availableBalance, - totalBalance, - }; -} - -export interface XLMPrice { - usd: number; - source: 'coingecko'; -} - -export interface AssetPriceEstimate { - xlm: number; - source: 'sdex'; - method: 'midpoint' | 'best_bid' | 'best_ask'; - bestBid: number | null; - bestAsk: number | null; -} - -export interface AssetBalanceLike { - asset_type: string; - asset_code?: string; - asset_issuer?: string; -} - -function parseTopOfBookPrice(levels: Array<{ price?: string }> = []): number | null { - const price = parseFloat(levels[0]?.price ?? ''); - if (!Number.isFinite(price) || price <= 0) return null; - return price; -} - -export async function fetchXLMPrice(): Promise { - const cacheKey = 'xlm-price'; - const cached = stellarCache.get(cacheKey); - if (cached) return cached; - - const response = await fetch(COINGECKO_XLM_PRICE_URL); - - if (!response.ok) { - throw new Error(`XLM price request failed: ${response.status}`); - } - - const data = await response.json(); - const usd = data?.stellar?.usd; - - if (!Number.isFinite(usd)) { - throw new Error('XLM price data unavailable'); - } - - const result: XLMPrice = { - usd, - source: 'coingecko', - }; - stellarCache.set(cacheKey, result, TTL.PRICE, ['price', 'xlm']); - return result; -} - -export async function fetchAssetPrice( - asset: AssetBalanceLike, - network: NetworkName = 'testnet' -): Promise { - if (!asset || asset.asset_type === 'native') return null; - - if (!asset.asset_type.startsWith('credit_alphanum') || !asset.asset_code || !asset.asset_issuer) { - return null; - } - - const cacheKey = `asset-price:${asset.asset_code}:${asset.asset_issuer}:${network}`; - const cached = stellarCache.get(cacheKey); - if (cached) return cached; - - const params = new URLSearchParams({ - selling_asset_type: asset.asset_type, - selling_asset_code: asset.asset_code, - selling_asset_issuer: asset.asset_issuer, - buying_asset_type: 'native', - }); - - const response = await fetch( - `${NETWORKS[network].horizonUrl}/order_book?${params.toString()}`, - withNetworkHeaders({}, network) - ); - - if (!response.ok) { - throw new Error(`Order book request failed: ${response.status}`); - } - - const orderBook = await response.json(); - const bestBid = parseTopOfBookPrice(orderBook.bids); - const bestAsk = parseTopOfBookPrice(orderBook.asks); - - let result: AssetPriceEstimate | null = null; - - if (bestBid !== null && bestAsk !== null) { - result = { - xlm: (bestBid + bestAsk) / 2, - source: 'sdex', - method: 'midpoint', - bestBid, - bestAsk, - }; - } else { - const fallback = bestBid ?? bestAsk; - if (fallback !== null) { - result = { - xlm: fallback, - source: 'sdex', - method: bestBid !== null ? 'best_bid' : 'best_ask', - bestBid, - bestAsk, - }; - } - } - - if (result) { - stellarCache.set(cacheKey, result, TTL.ASSET, ['price', asset.asset_code]); - } - return result; -} - -// ─── Faucet ─────────────────────────────────────────────────────────────────── - -export async function fundTestnetAccount(publicKey: string): Promise { - // Guard: faucet requires network writes and cannot be queued — block offline. - const { isWriteSafe } = await import('./offlineReadOnly'); - if (!isWriteSafe('faucet funding')) { - const { OfflineWriteError } = await import('./offlineReadOnly'); - throw new OfflineWriteError('faucet funding'); - } - const res = await fetch(`${NETWORKS.testnet.faucetUrl}?addr=${publicKey}`); - if (!res.ok) throw new Error('Faucet request failed'); - return res.json(); -} - -// ─── Contract ───────────────────────────────────────────────────────────────── - -export async function fetchContractInfo( - contractId: string, - network: NetworkName = 'testnet' -): Promise { - const server = getSorobanServer(network); - try { - const instance = await server.getContractData( - contractId, - StellarSdk.xdr.ScVal.scvLedgerKeyContractInstance(), - StellarSdk.rpc.Durability.Persistent - ); - return instance; - } catch (e) { - throw new Error(`Contract not found: ${(e as Error).message}`); - } -} - -export async function fetchContractData( - contractId: string, - key: StellarSdk.xdr.ScVal | string, - network: NetworkName = 'testnet', - durability: StellarSdk.rpc.Durability = StellarSdk.SorobanRpc.Durability.Persistent -): Promise { - const server = getSorobanServer(network); - - let scValKey; - if (typeof key === 'string') { - try { - // Try to parse from JSON first - const parsed = JSON.parse(key); - scValKey = StellarSdk.nativeToScVal(parsed); - } catch { - // If JSON fails, treat as string - scValKey = StellarSdk.nativeToScVal(key, { type: 'string' }); - } - } else { - scValKey = key; - } - - try { - const result = await server.getContractData(contractId, scValKey, durability); - return { - key: StellarSdk.scValToNative(result.key), - value: StellarSdk.scValToNative(result.val), - xdr: result.xdr, - }; - } catch (e) { - throw new Error(`Failed to fetch contract data: ${(e as Error).message}`); - } -} - -export interface ContractInvocationArg { - type: 'string' | 'int' | 'address' | 'bool'; - value: string; -} - -export interface SerializedLedgerKey { - type: string; - xdr: string; -} - -export interface SerializedContractEvent { - inSuccessfulContractCall: boolean; - type: string; - contractId: string | null; - topics: unknown[]; - value: unknown; -} - -export interface ContractSimulationResult { - xdr: string; - latestLedger: number; - cost?: StellarSdk.rpc.Api.Cost; - result: unknown; - events: SerializedContractEvent[]; - footprint: { - readOnly: SerializedLedgerKey[]; - readWrite: SerializedLedgerKey[]; - minResourceFee: string; - } | null; -} - -export interface ContractSubmitResult { - hash: string; - status: StellarSdk.rpc.Api.SendTransactionStatus; - errorResult: string | null; - diagnosticEvents: string[]; -} - -function getLedgerKeyType(key: StellarSdk.xdr.LedgerKey): string { - const kind = key.switch(); - return kind?.name || kind?.toString?.() || 'unknown'; -} - -function serializeLedgerKey(key: StellarSdk.xdr.LedgerKey): SerializedLedgerKey { - return { - type: getLedgerKeyType(key), - xdr: key.toXDR('base64'), - }; -} - -function serializeScVal(value: StellarSdk.xdr.ScVal): unknown { - try { - return StellarSdk.scValToNative(value); - } catch { - return value.toXDR('base64'); - } -} - -function serializeDiagnosticEvent(event: StellarSdk.xdr.DiagnosticEvent): SerializedContractEvent { - const contractEvent = event.event(); - const body = contractEvent.body().v0(); - const contractId = contractEvent.contractId(); - - return { - inSuccessfulContractCall: event.inSuccessfulContractCall(), - type: contractEvent.type().name || contractEvent.type().toString(), - contractId: contractId - ? StellarSdk.Address.fromScAddress( - contractId as unknown as StellarSdk.xdr.ScAddress - ).toString() - : null, - topics: body.topics().map(serializeScVal), - value: serializeScVal(body.data()), - }; -} - -function parseContractArgument(arg: ContractInvocationArg, index: number): StellarSdk.xdr.ScVal { - const trimmedValue = arg.value?.trim?.() ?? ''; - - if (!trimmedValue) { - throw new Error(`Argument ${index + 1} is empty`); - } - - switch (arg.type) { - case 'string': - return StellarSdk.nativeToScVal(trimmedValue, { type: 'string' }); - case 'int': { - let parsed: bigint; - try { - parsed = BigInt(trimmedValue); - } catch { - throw new Error(`Argument ${index + 1} must be a valid integer`); - } - return StellarSdk.nativeToScVal(parsed, { type: 'i128' }); - } - case 'address': - try { - return StellarSdk.Address.fromString(trimmedValue).toScVal(); - } catch { - throw new Error(`Argument ${index + 1} must be a valid Stellar address`); - } - case 'bool': - if (trimmedValue !== 'true' && trimmedValue !== 'false') { - throw new Error(`Argument ${index + 1} must be true or false`); - } - return StellarSdk.nativeToScVal(trimmedValue === 'true', { type: 'bool' }); - default: - throw new Error(`Unsupported argument type: ${arg.type}`); - } -} - -interface BuildContractInvocationParams { - contractId: string; - functionName: string; - args?: ContractInvocationArg[]; - sourceAccount: string; - network?: NetworkName; -} - -async function buildContractInvocationTransaction( - params: BuildContractInvocationParams -): Promise { - const { contractId, functionName, args = [], sourceAccount, network = 'testnet' } = params; - - if (!isValidContractId(contractId)) { - throw new Error('Invalid contract address'); - } - - if (!functionName.trim()) { - throw new Error('Function name is required'); - } - - if (!isValidPublicKey(sourceAccount)) { - throw new Error('A valid source account is required'); - } - - const horizon = getServer(network); - const account = await horizon.loadAccount(sourceAccount); - const contract = new StellarSdk.Contract(contractId.trim()); - const parsedArgs = args.map(parseContractArgument); - - return new StellarSdk.TransactionBuilder(account, { - fee: StellarSdk.BASE_FEE.toString(), - networkPassphrase: NETWORKS[network].passphrase, - }) - .setTimeout(30) - .addOperation(contract.call(functionName.trim(), ...parsedArgs)) - .build(); -} - -export async function simulateContractCall( - params: BuildContractInvocationParams -): Promise { - const { network = 'testnet' } = params; - const server = getSorobanServer(network); - const transaction = await buildContractInvocationTransaction(params); - const simulation = await server.simulateTransaction(transaction); - - if ('error' in simulation && simulation.error) { - throw new Error(simulation.error); - } - - const successfulSimulation = simulation as Exclude< - StellarSdk.rpc.Api.SimulateTransactionResponse, - StellarSdk.rpc.Api.SimulateTransactionErrorResponse - >; - - const footprint = successfulSimulation.transactionData - ? { - readOnly: successfulSimulation.transactionData.getReadOnly().map(serializeLedgerKey), - readWrite: successfulSimulation.transactionData.getReadWrite().map(serializeLedgerKey), - minResourceFee: successfulSimulation.minResourceFee, - } - : null; - - return { - xdr: transaction.toXDR(), - latestLedger: successfulSimulation.latestLedger, - cost: successfulSimulation.cost, - result: successfulSimulation.result ? serializeScVal(successfulSimulation.result.retval) : null, - events: (successfulSimulation.events || []).map(serializeDiagnosticEvent), - footprint, - }; -} - -interface InvokeContractParams { - contractId: string; - functionName: string; - args?: ContractInvocationArg[]; - secretKey: string; - network?: NetworkName; -} - -export async function invokeContract(params: InvokeContractParams): Promise { - const { contractId, functionName, args = [], secretKey, network = 'testnet' } = params; - - if (network !== 'testnet') { - throw new Error('Transaction submission is only enabled on Testnet'); - } - - if (!secretKey.trim()) { - throw new Error('Secret key is required to submit a transaction'); - } - - // Guard: contract invocation submits to the network — block offline unless queued. - const { isWriteSafe } = await import('./offlineReadOnly'); - if (!isWriteSafe('contract invocation')) { - const { OfflineWriteError } = await import('./offlineReadOnly'); - throw new OfflineWriteError('contract invocation'); - } - - let keypair: StellarSdk.Keypair; - try { - keypair = StellarSdk.Keypair.fromSecret(secretKey.trim()); - } catch { - throw new Error('Invalid secret key'); - } - - const sourceAccount = keypair.publicKey(); - const server = getSorobanServer(network); - const transaction = await buildContractInvocationTransaction({ - contractId, - functionName, - args, - sourceAccount, - network, - }); - const prepared = await server.prepareTransaction(transaction); - - prepared.sign(keypair); - - const response = await server.sendTransaction(prepared); - - return { - hash: response.hash, - status: response.status, - errorResult: response.errorResult ? response.errorResult.toXDR('base64') : null, - diagnosticEvents: (response.diagnosticEvents || []).map((event) => event.toXDR('base64')), - }; -} - -// ─── Validators ─────────────────────────────────────────────────────────────── - -/** - * Check if address is a valid Ed25519 public key (G...) - */ -export function isValidEd25519PublicKey(key: string): boolean { - return StellarSdk.StrKey.isValidEd25519PublicKey(key); -} - -/** - * Check if address is a valid muxed account (M...) - */ -export function isValidMuxedAccount(key: string): boolean { - if (!key || typeof key !== 'string') return false; - const trimmed = key.trim(); - if (!trimmed.startsWith('M')) return false; - try { - StellarSdk.MuxedAccount.fromAddress(trimmed, '0'); - return true; - } catch { - return false; - } -} - -/** - * Check if address is a federated address (name*domain or name@domain) - */ -export function isFederatedAddress(input: string): boolean { - if (typeof input !== 'string' || !input.trim()) return false; - const trimmed = input.trim(); - // name*domain.tld (Stellar federation) OR name@domain.tld (email-style) - return ( - /^[a-zA-Z0-9._-]+\*[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(trimmed) || - /^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(trimmed) - ); -} - -/** - * Extract master account and muxed ID from a muxed address - */ -export function parseMuxedAccount( - muxedAddress: string -): { masterAccount: string; muxedId: string } | null { - try { - const muxed = StellarSdk.MuxedAccount.fromAddress(muxedAddress, '0'); - return { - masterAccount: muxed.baseAccount().accountId(), - muxedId: muxed.id(), - }; - } catch { - return null; - } -} - -// ─── Memo requirement check (SEP-29) ─────────────────────────────────────────── - -export interface MemoRequirementResult { - /** True only when the destination account has published a SEP-29 memo requirement. */ - required: boolean; - /** True when the lookup completed (successfully or with a definitive "not found"). */ - checked: boolean; - /** Present when the requirement could not be determined (network error, unsupported input). */ - error?: string; -} - -/** - * Check whether a destination requires a memo per SEP-29, by looking for a - * `config.memo_required` data entry (base64-encoded "1") on the account. - * Exchanges and custodians commonly set this flag on shared deposit addresses. - * - * Muxed accounts (M...) already encode their own memo ID and never require one. - * Federated (name*domain) and contract (C...) destinations are not currently - * checked here — federation records surface their own memo via resolveAddress(). - */ -export async function checkDestinationMemoRequirement( - destination: string, - network: NetworkName = 'testnet' -): Promise { - if (typeof destination !== 'string' || !destination.trim()) { - return { required: false, checked: false, error: 'No destination provided.' }; - } - const trimmed = destination.trim(); - - if (isValidMuxedAccount(trimmed)) { - return { required: false, checked: true }; - } - - if (!isValidEd25519PublicKey(trimmed)) { - return { required: false, checked: false, error: 'Destination is not a directly checkable account address.' }; - } - - try { - const server = getServer(network); - const account = await server.loadAccount(trimmed); - const raw = (account as unknown as { data_attr?: Record }).data_attr?.['config.memo_required']; - if (!raw) return { required: false, checked: true }; - const decoded = typeof atob === 'function' ? atob(raw) : Buffer.from(raw, 'base64').toString('utf8'); - return { required: decoded === '1', checked: true }; - } catch (error: any) { - if (error?.response?.status === 404) { - // Unfunded accounts cannot carry the SEP-29 data entry. - return { required: false, checked: true }; - } - return { - required: false, - checked: false, - error: error?.message || 'Failed to check destination memo requirement.', - }; - } -} - -/** - * Resolve a federated address to a Stellar account via Horizon federation endpoint - */ -export async function resolveFederatedAddress( - federatedAddress: string, - network: NetworkName = 'testnet' -): Promise<{ accountId: string; memoId?: string; memoType?: string } | null> { - try { - // Parse the federated address (name*domain) - const [name, domain] = federatedAddress.split('*'); - - if (!name || !domain) { - return null; - } - - // Fetch the federation record from the domain's .well-known/stellar.toml - const federationUrl = `https://${domain}/.well-known/stellar.toml`; - - let tomlData: Record = {}; - try { - const tomlResponse = await rateLimitedFetch(federationUrl); - if (!tomlResponse.ok) { - // Federation endpoint not available in current SDK version - return null; - } - - // Parse TOML (basic parsing for FEDERATION_SERVER URL) - const tomlText = await tomlResponse.text(); - const federationServerMatch = tomlText.match(/FEDERATION_SERVER\s*=\s*"([^"]+)"/); - if (federationServerMatch) { - tomlData.federationServer = federationServerMatch[1]; - } - } catch { - // Federation endpoint not available in current SDK version - return null; - } - - // Use the federation server URL if found - if (tomlData.federationServer) { - const federationEndpoint = requireAllowedEndpoint( - tomlData.federationServer, - domain, - 'federation' - ); - federationEndpoint.searchParams.append('q', federatedAddress); - federationEndpoint.searchParams.append('type', 'name'); - - const response = await rateLimitedFetch(federationEndpoint.toString()); - if (response.ok) { - return await response.json(); - } - } - - // Federation endpoint not available in current SDK version - return null; - } catch { - return null; - } -} - -/** - * Comprehensive address resolver - * Accepts: G... (Ed25519), M... (muxed), or name*domain (federated) - * Returns: master account ID, muxed ID (if applicable), and original input info - */ -export interface ResolvedAddress { - accountId: string; // The master account ID (always G...) - muxedId?: string; // Muxed ID if input was M... - originalInput: string; // Original input provided - inputType: 'ed25519' | 'muxed' | 'federated'; - federatedAddress?: string; // Original federated address if applicable - memoId?: string; // Memo ID from federation resolution - memoType?: string; // Memo type from federation resolution -} - -export async function resolveAddress( - input: string, - network: NetworkName = 'testnet' -): Promise { - if (!input || typeof input !== 'string') { - return null; - } - - const trimmedInput = input.trim(); - - // Try Ed25519 public key (G...) - if (isValidEd25519PublicKey(trimmedInput)) { - return { - accountId: trimmedInput, - originalInput: trimmedInput, - inputType: 'ed25519', - }; - } - - // Try muxed account (M...) - if (isValidMuxedAccount(trimmedInput)) { - const parsed = parseMuxedAccount(trimmedInput); - if (parsed) { - return { - accountId: parsed.masterAccount, - muxedId: parsed.muxedId, - originalInput: trimmedInput, - inputType: 'muxed', - }; - } - } - - // Try federated address (name*domain) - if (isFederatedAddress(trimmedInput)) { - const resolved = await resolveFederatedAddress(trimmedInput, network); - if (resolved?.accountId) { - return { - accountId: resolved.accountId, - originalInput: trimmedInput, - inputType: 'federated', - federatedAddress: trimmedInput, - memoId: resolved.memoId, - memoType: resolved.memoType, - }; - } - } - - return null; -} - -/** - * Validate any supported address format (legacy function name for backward compatibility) - */ -export function isValidPublicKey(key: string): boolean { - if (!key || typeof key !== 'string') return false; - const trimmed = key.trim(); - - // Check G... Ed25519 - if (isValidEd25519PublicKey(trimmed)) return true; - - // Check M... muxed - if (isValidMuxedAccount(trimmed)) return true; - - // Check name*domain federated - if (isFederatedAddress(trimmed)) return true; - - return false; -} - -export function isValidContractId(id: string): boolean { - try { - StellarSdk.Address.fromString(id); - return true; - } catch { - return false; - } -} - -// ─── Claimable Balances ─────────────────────────────────────────────────────── - -export interface ClaimableBalanceRecord { - id: string; - asset: string; - amount: string; - sponsor: string; - last_modified_ledger: number; - claimants: Array<{ - destination: string; - predicate: Record; - }>; -} - -/** Human-readable summary of a claimant predicate. */ -export function formatClaimPredicate(predicate: Record): string { - if (!predicate || Object.keys(predicate).length === 0) return 'Unconditional'; - if ('unconditional' in predicate) return 'Unconditional'; - if ('abs_before' in predicate) return `Before ${predicate.abs_before}`; - if ('abs_after' in predicate) return `After ${predicate.abs_after}`; - if ('rel_before' in predicate) return `Within ${predicate.rel_before}s of claim`; - if ('and' in predicate) { - const parts = (predicate.and as Record[]).map(formatClaimPredicate); - return parts.join(' AND '); - } - if ('or' in predicate) { - const parts = (predicate.or as Record[]).map(formatClaimPredicate); - return parts.join(' OR '); - } - if ('not' in predicate) - return `NOT (${formatClaimPredicate(predicate.not as Record)})`; - return JSON.stringify(predicate); -} - -export async function fetchClaimableBalances( - publicKey: string, - network: NetworkName = 'testnet', - options: HorizonRequestOptions = {} -): Promise { - throwIfAborted(options.signal); - - const cacheKey = `claimable:${publicKey}:${network}`; - const cached = stellarCache.get(cacheKey); - if (cached) return cached; - - const config = NETWORKS[network]; - const url = `${config.horizonUrl}/claimable_balances?claimant=${encodeURIComponent(publicKey)}&limit=50`; - // This path goes through `fetch`, so the signal cancels the request on the wire. - const response = await rateLimitedFetch( - url, - options.signal ? { signal: options.signal } : undefined, - 'medium', - config.customHeaders - ); - - if (!response.ok) throw new Error(`Horizon error ${response.status}`); - - const data = await response.json(); - const records: ClaimableBalanceRecord[] = data._embedded?.records ?? []; - stellarCache.set(cacheKey, records, TTL.ACCOUNT, ['claimable', publicKey]); - return records; -} - -// ─── Claimable Balance predicate explanation ─────────────────────────────────── - -/** - * Structured, human-readable explanation of a claimable-balance claimant - * predicate. Used by the claimable-balance workspace to explain *when* and - * *whether* a balance can be claimed. - */ -export interface PredicateExplanation { - /** Normalized predicate kind. */ - kind: 'unconditional' | 'abs_before' | 'abs_after' | 'rel_before' | 'and' | 'or' | 'not' | 'unknown'; - /** Short human-readable summary. */ - summary: string; - /** Whether the predicate currently permits a claim (best-effort, null = unknown). */ - claimableNow: boolean | null; - /** Epoch ms at/after which the balance becomes claimable, if determinable. */ - claimableAt: number | null; - /** Sub-explanations for `and` / `or` / `not` predicates. */ - children?: PredicateExplanation[]; -} - -function toEpochMs(value: unknown): number | null { - if (typeof value === 'number') return value * 1000; // Horizon timestamps are seconds - if (typeof value === 'string') { - const n = Number(value); - if (!Number.isNaN(n) && value.trim() !== '') return n * 1000; - const t = Date.parse(value); - if (!Number.isNaN(t)) return t; - } - return null; -} - -/** Combine child `claimableNow` results for an AND predicate. */ -function combineAnd(children: PredicateExplanation[]): boolean | null { - if (children.length === 0) return null; - if (children.some((c) => c.claimableNow === false)) return false; - if (children.some((c) => c.claimableNow === null)) return null; - return true; -} - -/** Combine child `claimableNow` results for an OR predicate. */ -function combineOr(children: PredicateExplanation[]): boolean | null { - if (children.length === 0) return null; - if (children.some((c) => c.claimableNow === true)) return true; - if (children.some((c) => c.claimableNow === null)) return null; - return false; -} - -/** - * Explain a claimant predicate deterministically. - * - * @param predicate - Raw Horizon predicate object (or already a string/object) - * @param now - Reference timestamp in ms (defaults to `Date.now()`); injectable - * for tests and deterministic rendering. - * @returns {PredicateExplanation} - */ -export function explainClaimPredicate(predicate: unknown, now: number = Date.now()): PredicateExplanation { - if (!predicate || typeof predicate !== 'object' || Array.isArray(predicate) || Object.keys(predicate).length === 0) { - return { - kind: 'unconditional', - summary: 'Unconditional — claimable by the recipient at any time', - claimableNow: true, - claimableAt: null, - }; - } - - const p = predicate as Record; - - if ('unconditional' in p) { - return { - kind: 'unconditional', - summary: 'Unconditional — claimable by the recipient at any time', - claimableNow: true, - claimableAt: null, - }; - } - - if ('abs_before' in p) { - const ms = toEpochMs(p.abs_before); - if (ms == null) { - return { kind: 'abs_before', summary: `Before ${String(p.abs_before)}`, claimableNow: null, claimableAt: null }; - } - return { - kind: 'abs_before', - summary: `Claimable before ${new Date(ms).toUTCString()}`, - claimableNow: now < ms, - claimableAt: null, - }; - } - - if ('abs_after' in p) { - const ms = toEpochMs(p.abs_after); - if (ms == null) { - return { kind: 'abs_after', summary: `After ${String(p.abs_after)}`, claimableNow: null, claimableAt: null }; - } - return { - kind: 'abs_after', - summary: `Claimable after ${new Date(ms).toUTCString()}`, - claimableNow: now >= ms, - claimableAt: ms, - }; - } - - if ('rel_before' in p) { - const secs = Number(p.rel_before); - if (Number.isNaN(secs)) { - return { kind: 'rel_before', summary: `Within ${String(p.rel_before)}s of claim`, claimableNow: null, claimableAt: null }; - } - return { - kind: 'rel_before', - summary: `Claimable within ${secs}s of the balance's creation ledger`, - claimableNow: null, - claimableAt: null, - }; - } - - if ('and' in p) { - const children = Array.isArray(p.and) ? (p.and as unknown[]).map((c) => explainClaimPredicate(c, now)) : []; - return { - kind: 'and', - summary: `All of: ${children.map((c) => c.summary).join(' AND ')}`, - claimableNow: combineAnd(children), - claimableAt: null, - children, - }; - } - - if ('or' in p) { - const children = Array.isArray(p.or) ? (p.or as unknown[]).map((c) => explainClaimPredicate(c, now)) : []; - return { - kind: 'or', - summary: `Any of: ${children.map((c) => c.summary).join(' OR ')}`, - claimableNow: combineOr(children), - claimableAt: null, - children, - }; - } - - if ('not' in p) { - const child = explainClaimPredicate(p.not, now); - return { - kind: 'not', - summary: `NOT (${child.summary})`, - claimableNow: child.claimableNow === null ? null : !child.claimableNow, - claimableAt: null, - children: [child], - }; - } - - return { - kind: 'unknown', - summary: JSON.stringify(predicate), - claimableNow: null, - claimableAt: null, - }; -} - -// ─── Claimable Balance predicate builder ─────────────────────────────────────── - -/** - * Declarative spec for building a Stellar claimable-balance claimant predicate. - * Kept UI-agnostic and pure so it can be unit-tested without the Stellar SDK. - */ -export type PredicateSpec = - | { type: 'unconditional' } - | { type: 'before'; date: string | Date } - | { type: 'after'; date: string | Date } - | { type: 'relative'; seconds: number } - | { type: 'and'; predicates: PredicateSpec[] } - | { type: 'or'; predicates: PredicateSpec[] } - | { type: 'not'; predicate: PredicateSpec }; - -function toISODate(value: string | Date): string | null { - if (value instanceof Date) return value.toISOString(); - if (typeof value === 'string') { - const t = Date.parse(value); - if (!Number.isNaN(t)) return new Date(t).toISOString(); - } - return null; -} - -/** - * Build a Stellar claimant predicate object from a declarative {@link PredicateSpec}. - * - * @param spec - The predicate specification - * @returns A plain predicate object consumable by `StellarSdk.Claimant` - * @throws {TypeError} If the spec is invalid (bad date, non-positive relative - * time, too few operands, or unknown type) - */ -export function buildClaimPredicate(spec: PredicateSpec): Record { - if (!spec || typeof spec !== 'object') { - throw new TypeError('buildClaimPredicate: spec must be an object'); - } - switch (spec.type) { - case 'unconditional': - return { unconditional: null }; - case 'before': { - const iso = toISODate(spec.date); - if (!iso) throw new TypeError('buildClaimPredicate: "before" requires a valid date'); - return { abs_before: iso }; - } - case 'after': { - const iso = toISODate(spec.date); - if (!iso) throw new TypeError('buildClaimPredicate: "after" requires a valid date'); - return { abs_after: iso }; - } - case 'relative': { - const secs = Number(spec.seconds); - if (!Number.isFinite(secs) || secs <= 0) { - throw new TypeError('buildClaimPredicate: "relative" requires a positive number of seconds'); - } - return { rel_before: Math.floor(secs) }; - } - case 'and': - case 'or': { - const list = spec.predicates; - if (!Array.isArray(list) || list.length < 2) { - throw new TypeError(`buildClaimPredicate: "${spec.type}" requires at least two predicates`); - } - return { [spec.type]: list.map(buildClaimPredicate) }; - } - case 'not': { - if (!spec.predicate) throw new TypeError('buildClaimPredicate: "not" requires a predicate'); - return { not: buildClaimPredicate(spec.predicate) }; - } - default: - throw new TypeError(`buildClaimPredicate: unknown predicate type "${(spec as { type?: string }).type}"`); - } -} - -// ─── Claimable Balance inspection ────────────────────────────────────────────── - -/** - * Fetch a single claimable balance by its id (Horizon `GET /claimable_balances/:id`). - * - * Used by the workspace "inspect" view to show full claimant/predicate detail. - * - * @param balanceId - Horizon claimable-balance id (non-empty string) - * @param network - Target network - * @returns {Promise} - * @throws {TypeError} If `balanceId` is not a non-empty string - * @throws {Error} If the network is unsupported or Horizon returns an error - * (404 → "not found") - */ -export async function fetchClaimableBalanceById( - balanceId: string, - network: NetworkName = 'testnet' -): Promise { - if (typeof balanceId !== 'string' || balanceId.trim().length === 0) { - throw new TypeError('fetchClaimableBalanceById: balanceId must be a non-empty string'); - } - const config = NETWORKS[network]; - if (!config || !config.horizonUrl) { - throw new Error(`fetchClaimableBalanceById: unsupported network "${network}"`); - } - - const url = `${config.horizonUrl}/claimable_balances/${encodeURIComponent(balanceId)}`; - const response = await rateLimitedFetch(url, undefined, 'medium', config.customHeaders); - - if (response.status === 404) { - throw new Error(`Claimable balance ${balanceId} not found`); - } - if (!response.ok) { - throw new Error(`Horizon error ${response.status} while fetching claimable balance`); - } - - const data = await response.json(); - return data as ClaimableBalanceRecord; -} - -// ─── Formatters ─────────────────────────────────────────────────────────────── - -export function formatXLM(amount: string | number): string { - return parseFloat(String(amount)).toLocaleString('en-US', { - minimumFractionDigits: 2, - maximumFractionDigits: 7, - }); -} - -export function shortAddress(addr: string | null | undefined, chars = 6): string { - if (!addr) return ''; - return `${addr.slice(0, chars)}…${addr.slice(-chars)}`; -} - -// ─── Transaction builder ────────────────────────────────────────────────────── - -export type OperationType = 'payment' | 'createAccount'; - -export interface PaymentOperation { - type: 'payment'; - destination: string; - amount: string; -} - -export interface CreateAccountOperation { - type: 'createAccount'; - destination: string; - startingBalance: string; -} - -export interface InvokeHostFunctionOperation { - type: 'invokeHostFunction'; - [key: string]: any; -} - -export type BuilderOperation = - | PaymentOperation - | CreateAccountOperation - | InvokeHostFunctionOperation; - -export interface TimeBounds { - minTime?: string | number; - maxTime?: string | number; -} - -export interface TransactionPreconditions { - ledgerBounds?: { - minLedger?: number | string; - maxLedger?: number | string; - }; - minSequence?: number | string; - minSequenceAge?: number | string; - minSequenceLedgerGap?: number | string; - extraSigners?: string[]; -} - -export interface BuildTransactionParams { - sourceAccount: string; - operations: BuilderOperation[]; - memo?: string; - baseFee: number; - timeBounds: TimeBounds; - preconditions?: TransactionPreconditions; - network: NetworkName; -} - -export async function buildTransaction( - params: BuildTransactionParams -): Promise { - const { sourceAccount, operations, memo, baseFee, timeBounds, preconditions, network } = params; - - // ── Fee-bump shortcut ────────────────────────────────────────────────────── - // A fee-bump must be the only operation and is built entirely from its own - // params — it doesn't need a source account or sequence number load. - if (operations.length === 1 && operations[0].type === 'feeBump') { - const op = operations[0] as any; - const { feeSource, baseFee: fbFee, innerTransaction } = op; - - if (!feeSource || !isValidPublicKey(feeSource)) { - throw new Error('Fee-bump: feeSource must be a valid Stellar public key.'); - } - const fee = parseInt(fbFee, 10); - if (!Number.isFinite(fee) || fee <= 0) { - throw new Error('Fee-bump: baseFee must be a positive integer (stroops).'); - } - if (!innerTransaction || typeof innerTransaction !== 'string' || innerTransaction.trim() === '') { - throw new Error('Fee-bump: innerTransaction XDR is required.'); - } - - try { - const innerTx = new StellarSdk.Transaction(innerTransaction.trim(), NETWORKS[network].passphrase); - return StellarSdk.TransactionBuilder.buildFeeBumpTransaction( - feeSource, - fee.toString(), - innerTx, - NETWORKS[network].passphrase, - ); - } catch (err) { - throw new Error(`Fee-bump: failed to wrap inner transaction — ${(err as Error).message}`); - } - } - - if (operations.some((op) => op.type === 'feeBump')) { - throw new Error('A fee-bump operation must be the only operation in the transaction.'); - } - // ────────────────────────────────────────────────────────────────────────── - - const server = getServer(network); - const account = await server.loadAccount(sourceAccount); - - const txBuilder = new StellarSdk.TransactionBuilder(account, { - fee: baseFee.toString(), - networkPassphrase: NETWORKS[network].passphrase, - }); - - if (timeBounds.minTime || timeBounds.maxTime) { - txBuilder.setTimeout( - timeBounds.maxTime ? parseInt(String(timeBounds.maxTime)) - Math.floor(Date.now() / 1000) : 0 - ); - } - - if (preconditions) { - if (preconditions.ledgerBounds) { - const minLedger = parseInt(String(preconditions.ledgerBounds.minLedger || 0), 10); - const maxLedger = parseInt(String(preconditions.ledgerBounds.maxLedger || 0), 10); - txBuilder.setLedgerbounds(minLedger, maxLedger); - } - - if (preconditions.minSequence !== undefined && preconditions.minSequence !== '') { - txBuilder.setMinAccountSequence(parseInt(String(preconditions.minSequence), 10)); - } - - if (preconditions.minSequenceAge !== undefined && preconditions.minSequenceAge !== '') { - txBuilder.setMinAccountSequenceAge(parseInt(String(preconditions.minSequenceAge), 10)); - } - - if (preconditions.minSequenceLedgerGap !== undefined && preconditions.minSequenceLedgerGap !== '') { - txBuilder.setMinAccountSequenceLedgerGap(parseInt(String(preconditions.minSequenceLedgerGap), 10)); - } - - if (preconditions.extraSigners && preconditions.extraSigners.length > 0) { - txBuilder.setExtraSigners(preconditions.extraSigners); - } - } - - operations.forEach((op) => { - if (op.type === 'payment') { - txBuilder.addOperation( - StellarSdk.Operation.payment({ - destination: op.destination, - asset: StellarSdk.Asset.native(), - amount: op.amount, - }) - ); - } else if (op.type === 'createAccount') { - txBuilder.addOperation( - StellarSdk.Operation.createAccount({ - destination: op.destination, - startingBalance: op.startingBalance, - }) - ); - } else if (op.type === 'invokeHostFunction') { - txBuilder.addOperation( - StellarSdk.Operation.invokeHostFunction({ - func: (op as any).func, - auth: (op as any).auth || [], - }) - ); - } - }); - - if (memo) { - const memoCheck = validateMemo(memo, 'text'); - if (!memoCheck.valid) { - throw new Error(memoCheck.errors[0]); - } - txBuilder.addMemo(StellarSdk.Memo.text(memo)); - } - - return txBuilder.build(); -} - -// ─── Simulate transaction ───────────────────────────────────────────────────── - -export interface SimulateResult { - fee: number; - operationCount: number; - success: boolean; - errors: string[]; - warnings?: string[]; - feeOptions?: SimulationFeeOption[]; - xdr?: string; - resourceUsage?: { - cpuInstructions: number; - memoryBytes: number; - ledgerReadWrite: number; - ledgerReadOnly: number; - }; - sorobanMetrics?: { - footprint: { - readOnly: SerializedLedgerKey[]; - readWrite: SerializedLedgerKey[]; - }; - resourceFee: string; - refundableFee?: string; - events?: SerializedContractEvent[]; - }; -} - -export async function simulateTransaction(params: BuildTransactionParams): Promise { - const cacheKey = buildSimulationCacheKey(params); - const cached = simulationCache.get(cacheKey); - if (cached) { - return cached; - } - - const feeBumpOnly = params.operations.length === 1 && params.operations[0].type === 'feeBump'; - - // validateSimulationParams requires a sourceAccount — skip that check for fee-bump - // since the fee source lives inside the operation params, not at the top level. - const validation = feeBumpOnly - ? { errors: [] as string[], warnings: [] as string[] } - : validateSimulationParams(params); - - const errors = [...validation.errors]; - const warnings = [...validation.warnings]; - let transaction: StellarSdk.Transaction | StellarSdk.FeeBumpTransaction | null = null; - let sorobanMetrics = undefined; - - if (!params.memo) { - const paymentDestinations = (params.operations || []) - .filter((op): op is PaymentOperation => op.type === 'payment' && Boolean((op as PaymentOperation).destination)) - .map((op) => op.destination); - - for (const destination of paymentDestinations) { - const memoCheck = await checkDestinationMemoRequirement(destination, params.network); - if (memoCheck.checked && memoCheck.required) { - warnings.push( - `Destination ${shortAddress(destination)} requires a memo (SEP-29). Add one before submitting or the network will reject this transaction.` - ); - } - // Unsupported environments (federated/contract addresses, network errors) - // are surfaced via memoCheck.error but are non-fatal — we skip the warning. - } - } - - if (errors.length === 0) { - try { - transaction = await buildTransaction(params); - } catch (error) { - errors.push(`Transaction assembly failed: ${(error as Error).message}`); - } - } - - const result: SimulateResult = { - fee: 0, - operationCount: params.operations.length, - success: false, - errors, - warnings: warnings.length ? warnings : undefined, - }; - - if (transaction) { - const fee = parseInt(transaction.fee.toString(), 10); - - // For fee-bump transactions, operation count = inner ops + 1 (the bump itself). - // For plain transactions, it's the direct operation count. - let operationCount: number; - if (transaction instanceof StellarSdk.FeeBumpTransaction) { - operationCount = transaction.innerTransaction.operations.length + 1; - } else { - operationCount = (transaction as StellarSdk.Transaction).operations.length; - } - - const feeOptions = getSimulationFeeOptions(params.baseFee, operationCount); - - const hasSorobanOps = params.operations.some((op) => op.type === 'invokeHostFunction'); - if (hasSorobanOps && transaction instanceof StellarSdk.Transaction) { - try { - const sorobanServer = getSorobanServer(params.network); - const simulation = await sorobanServer.simulateTransaction(transaction); - - if ('error' in simulation) { - errors.push(`Soroban simulation error: ${simulation.error}`); - } else { - const successfulSimulation = simulation as any; - if (successfulSimulation.transactionData) { - const readOnly = successfulSimulation.transactionData.getReadOnly(); - const readWrite = successfulSimulation.transactionData.getReadWrite(); - const cost = successfulSimulation.cost as { cpuInstructions?: number; memoryBytes?: number } | undefined; - const cpuInstructions = cost?.cpuInstructions ?? Math.max(100_000, readWrite.length * 200_000 + readOnly.length * 50_000 + operationCount * 25_000); - const memoryBytes = cost?.memoryBytes ?? Math.max(1024, (readWrite.length + readOnly.length) * 2048 + operationCount * 512); - const resourceFee = successfulSimulation.minResourceFee; - const refundableFee = resourceFee ? Math.floor(parseInt(resourceFee, 10) * 0.3) : undefined; - - sorobanMetrics = { - footprint: { - readOnly: readOnly.map(serializeLedgerKey), - readWrite: readWrite.map(serializeLedgerKey), - }, - resourceFee, - refundableFee: refundableFee?.toString(), - events: (successfulSimulation.events || []).map(serializeDiagnosticEvent), - }; - - result.resourceUsage = { - cpuInstructions, - memoryBytes, - ledgerReadWrite: readWrite.length, - ledgerReadOnly: readOnly.length, - }; - } - } - } catch (e) { - console.warn('Soroban simulation failed:', e); - } - } - - result.fee = fee; - result.operationCount = operationCount; - result.success = errors.length === 0; - result.xdr = transaction.toXDR(); - result.feeOptions = feeOptions; - result.sorobanMetrics = sorobanMetrics; - } - - simulationCache.set(cacheKey, result, TTL.SHORT, ['simulation', params.network]); - return result; -} - -export interface SimulationWhatIfScenario { - label: string; - baseFee?: number; - operationMultiplier?: number; - networkCongestion?: number; -} - -export interface SimulationFeeOption { - label: string; - fee: number; - expectedInclusion: 'slow' | 'standard' | 'priority'; -} - -export interface ExecutionTraceStep { - step: string; - status: 'ok' | 'warning' | 'error'; - detail: string; -} - -export interface AdvancedSimulationParams extends BuildTransactionParams { - scenarios?: SimulationWhatIfScenario[]; - currentLedgerLoad?: number; -} - -export interface AdvancedSimulationReport { - base: SimulateResult; - optimizedFee: number; - feeOptions: SimulationFeeOption[]; - successProbability: number; - executionTrace: ExecutionTraceStep[]; - scenarios: Array<{ - label: string; - estimatedFee: number; - successProbability: number; - }>; -} - -function clamp(value: number, min: number, max: number): number { - return Math.min(max, Math.max(min, value)); -} - -export function optimizeTransactionFee( - baseFee: number, - operationCount: number, - currentLedgerLoad = 0.5 -): number { - const congestionMultiplier = 1 + clamp(currentLedgerLoad, 0, 1.5) * 0.85; - const opWeight = 1 + Math.max(0, operationCount - 1) * 0.08; - return Math.ceil(baseFee * congestionMultiplier * opWeight); -} - -export function scoreTransactionSuccess( - simulation: SimulateResult, - currentLedgerLoad = 0.5 -): number { - if (!simulation.success) return 0; - - const errorPenalty = simulation.errors.length * 0.2; - const congestionPenalty = clamp(currentLedgerLoad, 0, 1.5) * 0.28; - const opPenalty = Math.max(0, simulation.operationCount - 2) * 0.04; - const raw = 0.95 - errorPenalty - congestionPenalty - opPenalty; - return clamp(raw, 0.05, 0.99); -} - -export function buildExecutionTrace( - params: BuildTransactionParams, - simulation: SimulateResult -): ExecutionTraceStep[] { - const steps: ExecutionTraceStep[] = [ - { - step: 'Validate source account', - status: isValidPublicKey(params.sourceAccount) ? 'ok' : 'error', - detail: isValidPublicKey(params.sourceAccount) - ? 'Source account format is valid.' - : 'Source account is not a valid ed25519 public key.', - }, - { - step: 'Assemble operations', - status: params.operations.length > 0 ? 'ok' : 'error', - detail: `${params.operations.length} operation(s) attached to transaction.`, - }, - { - step: 'Estimate fee and bounds', - status: params.baseFee >= 100 ? 'ok' : 'warning', - detail: `Base fee ${params.baseFee} stroops with ${params.timeBounds.maxTime ? 'custom' : 'default'} time bounds.`, - }, - { - step: 'Simulate preflight', - status: simulation.success ? 'ok' : 'error', - detail: simulation.success - ? 'Simulation succeeded with no blocking errors.' - : simulation.errors.join('; '), - }, - { - step: 'Soroban Resource Preview', - status: simulation.sorobanMetrics ? 'ok' : 'warning', - detail: simulation.sorobanMetrics - ? `Footprint: ${simulation.sorobanMetrics.footprint.readOnly.length} RO, ${simulation.sorobanMetrics.footprint.readWrite.length} RW keys. Min fee: ${simulation.sorobanMetrics.resourceFee} stroops.` - : 'Soroban metrics not available for this transaction.', - }, - ]; - - return steps; -} - -export async function runAdvancedTransactionSimulation( - params: AdvancedSimulationParams -): Promise { - const base = await simulateTransaction(params); - const operationCount = Math.max(1, params.operations.length); - const currentLedgerLoad = params.currentLedgerLoad ?? 0.55; - const optimizedFee = optimizeTransactionFee(params.baseFee, operationCount, currentLedgerLoad); - const successProbability = scoreTransactionSuccess(base, currentLedgerLoad); - const executionTrace = buildExecutionTrace(params, base); - - const feeOptions: SimulationFeeOption[] = [ - { - label: 'Slow / Cost Saver', - fee: Math.max(100, Math.floor(optimizedFee * 0.85)), - expectedInclusion: 'slow', - }, - { - label: 'Standard', - fee: optimizedFee, - expectedInclusion: 'standard', - }, - { - label: 'Priority', - fee: Math.ceil(optimizedFee * 1.2), - expectedInclusion: 'priority', - }, - ]; - - const scenarios = (params.scenarios || []).map((scenario) => { - const scenarioOps = Math.max( - 1, - Math.round(operationCount * (scenario.operationMultiplier ?? 1)) - ); - const scenarioFee = optimizeTransactionFee( - scenario.baseFee ?? params.baseFee, - scenarioOps, - scenario.networkCongestion ?? currentLedgerLoad - ); - - const scenarioProbability = clamp( - successProbability - (scenario.networkCongestion ?? currentLedgerLoad) * 0.15 + 0.05, - 0.03, - 0.99 - ); - - return { - label: scenario.label, - estimatedFee: scenarioFee, - successProbability: scenarioProbability, - }; - }); - - return { - base, - optimizedFee, - feeOptions, - successProbability, - executionTrace, - scenarios, - }; -} - -export async function exportTransactionXDR(params: BuildTransactionParams): Promise { - const transaction = await buildTransaction(params); - return transaction.toXDR(); -} - -// ─── Path payments ──────────────────────────────────────────────────────────── - -export type PathPaymentMode = 'strict-send' | 'strict-receive'; - -export interface PathAsset { - type: 'native' | 'credit'; - code: string; - issuer?: string; -} - -export interface PaymentPathRecord { - source_asset_type: string; - source_asset_code?: string; - source_asset_issuer?: string; - source_amount: string; - destination_asset_type: string; - destination_asset_code?: string; - destination_asset_issuer?: string; - destination_amount: string; - path: Array<{ - asset_type: string; - asset_code?: string; - asset_issuer?: string; - }>; - /** Slippage % vs best path — annotated client-side, not from Horizon */ - slippagePct?: string; -} - -export interface FetchPaymentPathsParams { - sourceAsset: PathAsset; - destAsset: PathAsset; - amount: string; - mode?: PathPaymentMode; - network?: NetworkName; -} - -export type PathPaymentErrorCode = - | 'INVALID_INPUT' - | 'UNSUPPORTED_NETWORK' - | 'HORIZON_ERROR' - | 'REQUEST_FAILED'; - -/** A user-safe path quote failure with a stable code for UI handling. */ -export class PathPaymentError extends Error { - constructor( - public readonly code: PathPaymentErrorCode, - message: string, - public readonly status?: number - ) { - super(message); - this.name = 'PathPaymentError'; - } -} - -// ─── Liquidity pools ───────────────────────────────────────────────────────── - -export interface LiquidityPoolReserve { - asset: string; - amount: string; -} - -export interface LiquidityPoolRecord { - id: string; - paging_token?: string; - fee_bp?: number; - type?: string; - total_trustlines?: string | number; - total_shares?: string; - reserves?: LiquidityPoolReserve[]; -} - -export interface LiquidityPoolPosition { - poolId: string; - balance: string; - limit?: string; - sharePercent: number; - pool: LiquidityPoolRecord | null; -} - -interface AccountLiquidityPoolBalance { - asset_type: 'liquidity_pool_shares'; - liquidity_pool_id: string; - balance: string; - limit?: string; -} - -function horizonUrl(network: NetworkName, path: string): string { - return `${NETWORKS[network]?.horizonUrl || NETWORKS.testnet.horizonUrl}${path}`; -} - -async function horizonJson(network: NetworkName, path: string): Promise { - const response = await fetch(horizonUrl(network, path)); - if (!response.ok) throw new Error(`Horizon request failed: ${response.status}`); - return response.json() as Promise; -} - -function poolAssetString(asset: PathAsset | string): string { - if (typeof asset === 'string') return asset === 'XLM' ? 'native' : asset; - if (asset.type === 'native') return 'native'; - return `${asset.code}:${asset.issuer}`; -} - -function poolRecords(payload: { - _embedded?: { records?: LiquidityPoolRecord[] }; - records?: LiquidityPoolRecord[]; -}): LiquidityPoolRecord[] { - return payload._embedded?.records ?? payload.records ?? []; -} - -export async function fetchLiquidityPools( - network: NetworkName = 'testnet', - limit = 50, - reserves?: Array -): Promise { - const params = new URLSearchParams({ limit: String(limit) }); - if (reserves?.length) { - params.set('reserves', reserves.map(poolAssetString).join(',')); - } - - const data = await horizonJson<{ _embedded?: { records?: LiquidityPoolRecord[] } }>( - network, - `/liquidity_pools?${params.toString()}` - ); - return poolRecords(data); -} - -export function fetchLiquidityPoolsByAssetPair( - assetA: PathAsset | string, - assetB: PathAsset | string, - network: NetworkName = 'testnet', - limit = 50 -): Promise { - return fetchLiquidityPools(network, limit, [assetA, assetB]); -} - -export function fetchLiquidityPoolById( - poolId: string, - network: NetworkName = 'testnet' -): Promise { - return horizonJson( - network, - `/liquidity_pools/${encodeURIComponent(poolId)}` - ); -} - -export async function fetchLiquidityPoolOperations( - poolId: string, - network: NetworkName = 'testnet', - limit = 50 -): Promise { - const params = new URLSearchParams({ order: 'desc', limit: String(limit) }); - const data = await horizonJson<{ - _embedded?: { records?: StellarSdk.Horizon.ServerApi.OperationRecord[] }; - }>(network, `/liquidity_pools/${encodeURIComponent(poolId)}/operations?${params.toString()}`); - return data._embedded?.records ?? []; -} - -export async function fetchAccountLiquidityPoolPositions( - publicKey: string, - network: NetworkName = 'testnet' -): Promise { - const account = await fetchAccount(publicKey, network); - const balances = account.balances.filter( - (balance) => balance.asset_type === 'liquidity_pool_shares' - ) as AccountLiquidityPoolBalance[]; - - return Promise.all( - balances.map(async (balance) => { - let pool: LiquidityPoolRecord | null = null; - try { - pool = await fetchLiquidityPoolById(balance.liquidity_pool_id, network); - } catch { - pool = null; - } - - const shares = parseFloat(balance.balance); - const totalShares = parseFloat(pool?.total_shares ?? '0'); - - return { - poolId: balance.liquidity_pool_id, - balance: balance.balance, - limit: balance.limit, - sharePercent: totalShares > 0 ? (shares / totalShares) * 100 : 0, - pool, - }; - }) - ); -} - -export async function fetchAccountLiquidityPoolHistory( - publicKey: string, - network: NetworkName = 'testnet', - limit = 50, - poolId: string | null = null -): Promise { - const server = getServer(network); - const ops = await server.operations().forAccount(publicKey).order('desc').limit(limit).call(); - return (ops.records || []).filter((op) => { - const isPoolOperation = - op.type === 'liquidity_pool_deposit' || op.type === 'liquidity_pool_withdraw'; - if (!isPoolOperation) return false; - return !poolId || (op as { liquidity_pool_id?: string }).liquidity_pool_id === poolId; - }); -} - -// ─── Asset Discovery & Analytics ───────────────────────────────────────────── - -export interface AssetInfo { - code: string; - issuer: string; - domain?: string; - name?: string; - description?: string; - image?: string; - conditions?: string; - is_verified?: boolean; - is_asset_anchored?: boolean; - anchor_asset_type?: string; - anchor_asset?: string; - redemption_instructions?: string; - collateral_addresses?: string[]; - collateral_address_messages?: string[]; - status?: string; - display_decimals?: number; - num_accounts?: number; - amount?: string; - flags?: { - auth_required?: boolean; - auth_revocable?: boolean; - auth_immutable?: boolean; - auth_clawback_enabled?: boolean; - }; - paging_token?: string; -} - -export interface AssetStats { - asset: AssetInfo; - num_accounts: number; - num_claimable_balances: number; - num_liquidity_pools: number; - num_contracts: number; - amount: string; - accounts: { - authorized: number; - authorized_to_maintain_liabilities: number; - unauthorized: number; - }; - balances: { - authorized: string; - authorized_to_maintain_liabilities: string; - unauthorized: string; - }; - claimable_balances_amount: string; - liquidity_pools_amount: string; - contracts_amount: string; -} - -export interface AssetMarketData { - asset: AssetInfo; - price_usd?: number; - price_xlm?: number; - volume_24h_usd?: number; - volume_24h_xlm?: number; - market_cap_usd?: number; - change_24h?: number; - high_24h?: number; - low_24h?: number; - last_updated?: string; - trading_pairs?: TradingPair[]; -} - -export interface TradingPair { - base_asset: AssetInfo; - counter_asset: AssetInfo; - price: string; - volume_24h: string; - change_24h: string; - high_24h: string; - low_24h: string; - last_trade_at: string; -} - -export interface IssuerInfo { - account_id: string; - domain?: string; - name?: string; - description?: string; - website?: string; - logo?: string; - support_email?: string; - support_url?: string; - keybase?: string; - twitter?: string; - github?: string; - telegram?: string; - linkedin?: string; - facebook?: string; - medium?: string; - reddit?: string; - is_verified?: boolean; - verification_level?: 'none' | 'domain' | 'manual' | 'full'; - assets_issued?: AssetInfo[]; - toml_url?: string; - toml_last_updated?: string; -} - -export interface AssetSearchFilters { - query?: string; - asset_type?: 'credit_alphanum4' | 'credit_alphanum12' | 'native'; - asset_issuer?: string; - verified_only?: boolean; - min_accounts?: number; - max_accounts?: number; - min_amount?: string; - max_amount?: string; - has_domain?: boolean; - order?: 'asc' | 'desc'; - sort_by?: 'code' | 'num_accounts' | 'amount' | 'created_at'; - limit?: number; - cursor?: string; -} - -export interface TrustlineRecommendation { - asset: AssetInfo; - issuer_info: IssuerInfo; - recommendation_score: number; - reasons: string[]; - risk_factors: string[]; - similar_assets?: AssetInfo[]; - market_data?: AssetMarketData; -} - -// Popular assets list (curated) -export const POPULAR_ASSETS: AssetInfo[] = [ - { - code: 'USDC', - issuer: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', - domain: 'centre.io', - name: 'USD Coin', - description: 'USDC is a fully collateralized US dollar stablecoin', - is_verified: true, - is_asset_anchored: true, - anchor_asset_type: 'fiat', - anchor_asset: 'USD', - }, - { - code: 'AQUA', - issuer: 'GBNZILSTVQZ4R7IKQDGHYGY2QXL5QOFJYQMXPKWRRM5PAV7Y4M67AQUA', - domain: 'aqua.network', - name: 'Aqua Token', - description: 'AQUA is the native token of the Aqua Network', - is_verified: true, - }, - { - code: 'yXLM', - issuer: 'GARDNV3Q7YGT4AKSDF25LT32YSCCW67UUQRWQGZ2FQOTQADAAY6RQXU', - domain: 'ultrastellar.com', - name: 'yXLM', - description: 'Yield-bearing XLM token', - is_verified: true, - }, - { - code: 'MOBI', - issuer: 'GA6HCMBLTZS5VYYBCATRBRZ3BZJMAFUDKYYF6AH6MVCMGWMRDNSWJPIH', - domain: 'mobius.network', - name: 'Mobius Token', - description: 'MOBI is the native token of the Mobius Network', - is_verified: true, - }, -]; - -/** - * Fetch all assets from Horizon - */ -export async function fetchAssets( - network: NetworkName = 'testnet', - filters: AssetSearchFilters = {} -): Promise<{ records: AssetInfo[]; next?: string; prev?: string }> { - const server = getServer(network); - - let assetsCall = server.assets(); - - if (filters.asset_issuer) { - assetsCall = assetsCall.forIssuer(filters.asset_issuer); - } - - if (filters.asset_type) { - // Note: Horizon doesn't have direct asset type filtering, we'll filter client-side - } - - if (filters.order) { - assetsCall = assetsCall.order(filters.order); - } - - if (filters.limit) { - assetsCall = assetsCall.limit(filters.limit); - } - - if (filters.cursor) { - assetsCall = assetsCall.cursor(filters.cursor); - } - - const response = await assetsCall.call(); - - let assets = response.records.map( - (asset: any): AssetInfo => ({ - code: asset.asset_code, - issuer: asset.asset_issuer, - num_accounts: parseInt(asset.num_accounts), - amount: asset.amount, - flags: { - auth_required: asset.flags.auth_required, - auth_revocable: asset.flags.auth_revocable, - auth_immutable: asset.flags.auth_immutable, - auth_clawback_enabled: asset.flags.auth_clawback_enabled, - }, - paging_token: asset.paging_token, - }) - ); - - // Client-side filtering - if (filters.query) { - const query = filters.query.toLowerCase(); - assets = assets.filter( - (asset) => - asset.code.toLowerCase().includes(query) || - asset.issuer.toLowerCase().includes(query) || - asset.domain?.toLowerCase().includes(query) || - asset.name?.toLowerCase().includes(query) - ); - } - - if (filters.verified_only) { - assets = assets.filter((asset) => asset.is_verified); - } - - if (filters.min_accounts) { - assets = assets.filter((asset) => (asset.num_accounts || 0) >= filters.min_accounts!); - } - - if (filters.max_accounts) { - assets = assets.filter((asset) => (asset.num_accounts || 0) <= filters.max_accounts!); - } - - if (filters.has_domain) { - assets = assets.filter((asset) => !!asset.domain); - } - - return { - records: assets, - next: response.records[response.records.length - 1]?.paging_token, - prev: response.records[0]?.paging_token, - }; -} - -/** - * Fetch detailed asset statistics - */ -export async function fetchAssetStats( - assetCode: string, - assetIssuer: string, - network: NetworkName = 'testnet' -): Promise { - try { - const server = getServer(network); - const asset = await server.assets().forCode(assetCode).forIssuer(assetIssuer).call(); - - if (asset.records.length === 0) return null; - - const assetData = asset.records[0]; - - return { - asset: { - code: assetData.asset_code, - issuer: assetData.asset_issuer, - num_accounts: Number(assetData.num_accounts), - amount: assetData.amount, - flags: { - auth_required: assetData.flags.auth_required, - auth_revocable: assetData.flags.auth_revocable, - auth_immutable: assetData.flags.auth_immutable, - auth_clawback_enabled: assetData.flags.auth_clawback_enabled, - }, - }, - num_accounts: Number(assetData.num_accounts), - num_claimable_balances: Number(assetData.num_claimable_balances || 0), - num_liquidity_pools: Number(assetData.num_liquidity_pools || 0), - num_contracts: Number(assetData.num_contracts || 0), - amount: assetData.amount, - accounts: { - authorized: Number(assetData.accounts?.authorized || 0), - authorized_to_maintain_liabilities: Number( - assetData.accounts?.authorized_to_maintain_liabilities || 0 - ), - unauthorized: Number(assetData.accounts?.unauthorized || 0), - }, - balances: { - authorized: assetData.balances?.authorized || '0', - authorized_to_maintain_liabilities: - assetData.balances?.authorized_to_maintain_liabilities || '0', - unauthorized: assetData.balances?.unauthorized || '0', - }, - claimable_balances_amount: assetData.claimable_balances_amount || '0', - liquidity_pools_amount: assetData.liquidity_pools_amount || '0', - contracts_amount: assetData.contracts_amount || '0', - }; - } catch (error) { - console.error('Error fetching asset stats:', error); - return null; - } -} - -/** - * Fetch issuer information from stellar.toml - */ -export async function fetchIssuerInfo( - issuerAccount: string, - network: NetworkName = 'testnet' -): Promise { - try { - const server = getServer(network); - const account = await server.loadAccount(issuerAccount); - - let issuerInfo: IssuerInfo = { - account_id: issuerAccount, - verification_level: 'none', - }; - - // Check if account has a home domain - if (account.home_domain) { - issuerInfo.domain = account.home_domain; - issuerInfo.toml_url = `https://${account.home_domain}/.well-known/stellar.toml`; - - try { - // Fetch stellar.toml - const tomlResponse = await fetch(issuerInfo.toml_url); - if (tomlResponse.ok) { - const tomlText = await tomlResponse.text(); - const tomlData = parseToml(tomlText); - - // Extract organization info - if (tomlData.DOCUMENTATION) { - issuerInfo.name = tomlData.DOCUMENTATION.ORG_NAME; - issuerInfo.description = tomlData.DOCUMENTATION.ORG_DESCRIPTION; - issuerInfo.website = tomlData.DOCUMENTATION.ORG_URL; - issuerInfo.logo = tomlData.DOCUMENTATION.ORG_LOGO; - issuerInfo.support_email = tomlData.DOCUMENTATION.ORG_SUPPORT_EMAIL; - issuerInfo.support_url = tomlData.DOCUMENTATION.ORG_SUPPORT_URL; - issuerInfo.keybase = tomlData.DOCUMENTATION.ORG_KEYBASE; - issuerInfo.twitter = tomlData.DOCUMENTATION.ORG_TWITTER; - issuerInfo.github = tomlData.DOCUMENTATION.ORG_GITHUB; - } - - issuerInfo.verification_level = 'domain'; - issuerInfo.toml_last_updated = new Date().toISOString(); - } - } catch (tomlError) { - console.warn('Failed to fetch stellar.toml:', tomlError); - } - } - - return issuerInfo; - } catch (error) { - console.error('Error fetching issuer info:', error); - return { - account_id: issuerAccount, - verification_level: 'none', - }; - } -} - -/** - * Simple TOML parser for stellar.toml files - */ -function parseToml(tomlText: string): any { - const result: any = {}; - let currentSection = result; - let currentSectionName = ''; - - const lines = tomlText.split('\n'); - - for (const line of lines) { - const trimmed = line.trim(); - - // Skip empty lines and comments - if (!trimmed || trimmed.startsWith('#')) continue; - - // Section headers - if (trimmed.startsWith('[') && trimmed.endsWith(']')) { - currentSectionName = trimmed.slice(1, -1); - currentSection = result[currentSectionName] = {}; - continue; - } - - // Key-value pairs - const equalIndex = trimmed.indexOf('='); - if (equalIndex > 0) { - const key = trimmed.slice(0, equalIndex).trim(); - let value = trimmed.slice(equalIndex + 1).trim(); - - // Remove quotes - if ( - (value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'")) - ) { - value = value.slice(1, -1); - } - - currentSection[key] = value; - } - } - - return result; -} - -/** - * Get trustline recommendations for an account - */ -export async function getTrustlineRecommendations( - accountId: string, - network: NetworkName = 'testnet', - limit: number = 10 -): Promise { - try { - const server = getServer(network); - const account = await server.loadAccount(accountId); - - // Get current trustlines - const currentAssets = new Set( - account.balances - .filter((balance) => balance.asset_type !== 'native') - .map((balance) => { - const assetBalance = balance as unknown as { asset_code: string; asset_issuer: string }; - return `${assetBalance.asset_code}:${assetBalance.asset_issuer}`; - }) - ); - - // Fetch popular assets - const { records: popularAssets } = await fetchAssets(network, { - limit: 50, - order: 'desc', - }); - - const recommendations: TrustlineRecommendation[] = []; - - for (const asset of popularAssets) { - const assetKey = `${asset.code}:${asset.issuer}`; - - // Skip if already trusted - if (currentAssets.has(assetKey)) continue; - - // Get issuer info - const issuerInfo = await fetchIssuerInfo(asset.issuer, network); - - // Calculate recommendation score - let score = 0; - const reasons: string[] = []; - const riskFactors: string[] = []; - - // Scoring factors - if (asset.num_accounts && asset.num_accounts > 1000) { - score += 30; - reasons.push(`Popular asset with ${asset.num_accounts.toLocaleString()} holders`); - } - - if (issuerInfo.verification_level === 'domain') { - score += 25; - reasons.push('Verified issuer with domain'); - } - - if (asset.is_verified) { - score += 20; - reasons.push('Verified asset'); - } - - if (asset.flags?.auth_required) { - score -= 10; - riskFactors.push('Requires authorization from issuer'); - } - - if (asset.flags?.auth_revocable) { - score -= 15; - riskFactors.push('Issuer can revoke authorization'); - } - - if (asset.flags?.auth_clawback_enabled) { - score -= 20; - riskFactors.push('Issuer can clawback funds'); - } - - if (!issuerInfo.domain) { - score -= 15; - riskFactors.push('No domain verification'); - } - - // Only recommend assets with positive scores - if (score > 0) { - recommendations.push({ - asset, - issuer_info: issuerInfo, - recommendation_score: score, - reasons, - risk_factors: riskFactors, - }); - } - - if (recommendations.length >= limit) break; - } - - // Sort by recommendation score - return recommendations.sort((a, b) => b.recommendation_score - a.recommendation_score); - } catch (error) { - console.error('Error getting trustline recommendations:', error); - return []; - } -} - -/** - * Search assets with advanced filtering - */ -export async function searchAssets( - query: string, - network: NetworkName = 'testnet', - filters: AssetSearchFilters = {} -): Promise { - const searchFilters: AssetSearchFilters = { - ...filters, - query: query.trim(), - limit: filters.limit || 20, - }; - - const { records } = await fetchAssets(network, searchFilters); - - // Enhance with issuer info for top results - const enhancedAssets = await Promise.all( - records.slice(0, 10).map(async (asset) => { - try { - const issuerInfo = await fetchIssuerInfo(asset.issuer, network); - return { - ...asset, - domain: issuerInfo.domain, - name: issuerInfo.name, - description: issuerInfo.description, - is_verified: issuerInfo.verification_level !== 'none', - }; - } catch (error) { - return asset; - } - }) - ); - - return [...enhancedAssets, ...records.slice(10)]; -} - -/** - * Get asset market data (mock implementation - would integrate with real price APIs) - */ -export async function fetchAssetMarketData( - assetCode: string, - assetIssuer: string, - network: NetworkName = 'testnet' -): Promise { - try { - // This would integrate with real market data APIs like CoinGecko, CoinMarketCap, etc. - // For now, return mock data for popular assets - - const mockMarketData: Record> = { - 'USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN': { - price_usd: 1.0, - price_xlm: 8.33, // Assuming XLM = $0.12 - volume_24h_usd: 1500000, - market_cap_usd: 45000000000, - change_24h: 0.01, - high_24h: 1.001, - low_24h: 0.999, - }, - 'AQUA:GBNZILSTVQZ4R7IKQDGHYGY2QXL5QOFJYQMXPKWRRM5PAV7Y4M67AQUA': { - price_usd: 0.0045, - price_xlm: 0.0375, - volume_24h_usd: 125000, - change_24h: -2.5, - high_24h: 0.0048, - low_24h: 0.0043, - }, - }; - - const assetKey = `${assetCode}:${assetIssuer}`; - const marketData = mockMarketData[assetKey]; - - if (!marketData) return null; - - const assetStats = await fetchAssetStats(assetCode, assetIssuer, network); - - return { - asset: assetStats?.asset || { code: assetCode, issuer: assetIssuer }, - last_updated: new Date().toISOString(), - ...marketData, - } as AssetMarketData; - } catch (error) { - console.error('Error fetching asset market data:', error); - return null; - } -} - -export async function fetchPaymentPaths( - params: FetchPaymentPathsParams -): Promise { - const { sourceAsset, destAsset, amount, mode = 'strict-send', network = 'testnet' } = params; - - if (mode !== 'strict-send' && mode !== 'strict-receive') { - throw new PathPaymentError('INVALID_INPUT', 'Choose either strict-send or strict-receive mode.'); - } - - if (!/^\d+(\.\d{1,7})?$/.test(amount) || Number(amount) <= 0) { - throw new PathPaymentError( - 'INVALID_INPUT', - 'Amount must be a positive decimal with no more than 7 decimal places.' - ); - } - - function validateAsset(asset: PathAsset, label: string): void { - if (!asset || (asset.type !== 'native' && asset.type !== 'credit')) { - throw new PathPaymentError('INVALID_INPUT', `${label} asset type is invalid.`); - } - if (asset.type === 'credit') { - if (!/^[a-zA-Z0-9]{1,12}$/.test(asset.code)) { - throw new PathPaymentError('INVALID_INPUT', `${label} asset code must contain 1 to 12 letters or numbers.`); - } - if (!asset.issuer || !isValidPublicKey(asset.issuer)) { - throw new PathPaymentError('INVALID_INPUT', `${label} asset issuer must be a valid Stellar G address.`); - } - } - } - - validateAsset(sourceAsset, 'Source'); - validateAsset(destAsset, 'Destination'); - - const networkConfig = NETWORKS[network]; - if (!networkConfig?.horizonUrl) { - throw new PathPaymentError( - 'UNSUPPORTED_NETWORK', - `Path quotes are not available for the ${network || 'selected'} network.` - ); - } - const horizonUrl = networkConfig.horizonUrl.replace(/\/$/, ''); - - function assetParams(asset: PathAsset, prefix: string): string { - if (asset.type === 'native') { - return `${prefix}_asset_type=native`; - } - const alphaNum = asset.code.length <= 4 ? '4' : '12'; - return `${prefix}_asset_type=credit_alphanum${alphaNum}&${prefix}_asset_code=${asset.code}&${prefix}_asset_issuer=${asset.issuer}`; - } - - function assetString(asset: PathAsset): string { - if (asset.type === 'native') return 'native'; - return `${asset.code}:${asset.issuer}`; - } - - let url: string; - if (mode === 'strict-send') { - url = `${horizonUrl}/paths/strict-send?${assetParams(sourceAsset, 'source')}&source_amount=${amount}&destination_assets=${encodeURIComponent(assetString(destAsset))}`; - } else { - url = `${horizonUrl}/paths/strict-receive?${assetParams(destAsset, 'destination')}&destination_amount=${amount}&source_assets=${encodeURIComponent(assetString(sourceAsset))}`; - } - - let res: Response; - try { - res = await fetch(url, withNetworkHeaders({}, network)); - } catch { - throw new PathPaymentError( - 'REQUEST_FAILED', - 'Could not reach Horizon. Check your connection and try again.' - ); - } - - if (!res.ok) { - let detail = ''; - try { - const problem = (await res.json()) as { detail?: string }; - detail = problem.detail ? ` ${problem.detail}` : ''; - } catch { - // Horizon may return an empty or non-JSON proxy response. - } - throw new PathPaymentError( - 'HORIZON_ERROR', - `Horizon could not produce a path quote (${res.status}).${detail}`, - res.status - ); - } - - let records: PaymentPathRecord[]; - try { - const data = (await res.json()) as { _embedded?: { records?: PaymentPathRecord[] } }; - records = Array.isArray(data._embedded?.records) ? data._embedded.records : []; - } catch { - throw new PathPaymentError('HORIZON_ERROR', 'Horizon returned an invalid path quote response.'); - } - - const amountFor = (record: PaymentPathRecord) => Number( - mode === 'strict-send' ? record.destination_amount : record.source_amount - ); - const validRecords = records.filter((record) => Number.isFinite(amountFor(record)) && amountFor(record) > 0); - validRecords.sort((a, b) => mode === 'strict-send' - ? amountFor(b) - amountFor(a) - : amountFor(a) - amountFor(b)); - - const bestAmount = validRecords[0] ? amountFor(validRecords[0]) : 0; - return validRecords.map((record) => { - const quotedAmount = amountFor(record); - const slippage = mode === 'strict-send' - ? ((bestAmount - quotedAmount) / bestAmount) * 100 - : ((quotedAmount - bestAmount) / bestAmount) * 100; - return { ...record, slippagePct: slippage.toFixed(2) }; - }); -} - -/** - * Clear cache for specific pattern - * @param {string} pattern - Key pattern to clear - */ -export function clearCache(pattern: string | null = null) { - if (pattern) { - stellarCache.invalidatePrefix(pattern); - } else { - stellarCache.clear(); - } -} - -/** - * Get cache statistics - * @returns {object} Cache stats - */ -export function getCacheStats() { - return stellarCache.getStats(); -} - -export function formatInstructions(instructions: number): string { - if (instructions < 1000) return `${instructions}`; - if (instructions < 1000000) return `${(instructions / 1000).toFixed(2)}K`; - return `${(instructions / 1000000).toFixed(2)}M`; -} - -export function formatBytes(bytes: number): string { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`; - return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; -} - -export function formatStroops(stroops: unknown): string { - const num = typeof stroops === 'number' ? stroops : parseInt(String(stroops), 10); - if (isNaN(num)) return '—'; - const xlm = (num / 10000000).toFixed(7); - return `${xlm} XLM (${num.toLocaleString('en-US')} stroops)`; -} - -export { StellarSdk }; - -export default { - NETWORKS, - getNetworkDetails, - updateCustomNetworkConfig, - switchToCustomProfile, - loadCustomNetworkProfiles, - getServer, - getSorobanServer, - fetchAccount, - fetchTransactions, - fetchOperations, - fetchAccountOffers, - getOperationLabel, - fetchAccountCreationDate, - streamLedgers, - fetchNetworkStats, - fetchXLMPrice, - fetchAssetPrice, - fundTestnetAccount, - fetchContractInfo, - simulateContractCall, - invokeContract, - isValidPublicKey, - isValidEd25519PublicKey, - isValidMuxedAccount, - isFederatedAddress, - parseMuxedAccount, - checkDestinationMemoRequirement, - resolveFederatedAddress, - resolveAddress, - isValidContractId, - formatXLM, - shortAddress, - buildTransaction, - simulateTransaction, - runAdvancedTransactionSimulation, - exportTransactionXDR, - fetchPaymentPaths, - fetchLiquidityPools, - fetchLiquidityPoolsByAssetPair, - fetchLiquidityPoolById, - fetchLiquidityPoolOperations, - fetchAccountLiquidityPoolPositions, - fetchAccountLiquidityPoolHistory, - fetchAssets, - fetchAssetStats, - fetchIssuerInfo, - getTrustlineRecommendations, - searchAssets, - fetchAssetMarketData, - calculateAccountReserves, - clearCache, - getCacheStats, - formatInstructions, - formatBytes, - formatStroops, - importBatchXdr, - simulateBatchXdr, - validateXdrForBroadcast, - StellarSdk, -}; - -// ── Compatibility aliases ────────────────────────────────────────────────── -/** @deprecated Use {@link fetchAccount} directly. */ -export type AccountDetails = StellarSdk.Horizon.AccountResponse; -/** @deprecated Use {@link fetchAccount} directly. */ -export const fetchAccountDetails = fetchAccount; -/** @deprecated Use {@link isValidEd25519PublicKey} directly. */ -export const isPublicKey = isValidEd25519PublicKey; +export * from './stellar/index.js'; +export { default } from './stellar/default.js'; diff --git a/src/lib/stellar/__tests__/domains.test.ts b/src/lib/stellar/__tests__/domains.test.ts new file mode 100644 index 00000000..9e90e42c --- /dev/null +++ b/src/lib/stellar/__tests__/domains.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it, vi } from 'vitest'; +import { buildClaimPredicate, isValidPublicKey, resolveAddress } from '../index.js'; + +vi.mock('../../errorHandling/CircuitBreaker', () => ({ + getCircuitBreaker: vi.fn(), +})); + +const VALID_ACCOUNT = 'GATRGEIRAHUC2KD62STK4MCA2OXLIE5SSY67OCMREH5ZBI573EKGBRNI'; + +describe('Stellar domain modules', () => { + it('resolves a valid public account through the public barrel', async () => { + expect(isValidPublicKey(VALID_ACCOUNT)).toBe(true); + await expect(resolveAddress(VALID_ACCOUNT)).resolves.toMatchObject({ + accountId: VALID_ACCOUNT, + inputType: 'ed25519', + }); + }); + + it('rejects an unsupported or incomplete address at the format boundary', () => { + expect(isValidPublicKey('M')).toBe(false); + expect(isValidPublicKey('name*')).toBe(false); + }); + + it('rejects invalid relative claim predicates', () => { + expect(() => buildClaimPredicate({ type: 'relative', seconds: 0 })).toThrow(TypeError); + }); +}); \ No newline at end of file diff --git a/src/lib/stellar/addresses.ts b/src/lib/stellar/addresses.ts new file mode 100644 index 00000000..f8bd9e83 --- /dev/null +++ b/src/lib/stellar/addresses.ts @@ -0,0 +1,267 @@ +import * as StellarSdk from '@stellar/stellar-sdk'; +import { getServer, rateLimitedFetch, type NetworkName } from './networks.js'; +import { requireAllowedEndpoint } from '../endpointAllowlist'; + +// ─── Validators ─────────────────────────────────────────────────────────────── + +/** + * Check if address is a valid Ed25519 public key (G...) + */ +export function isValidEd25519PublicKey(key: string): boolean { + return StellarSdk.StrKey.isValidEd25519PublicKey(key); +} + +/** + * Check if address is a valid muxed account (M...) + */ +export function isValidMuxedAccount(key: string): boolean { + if (!key || typeof key !== 'string') return false; + const trimmed = key.trim(); + if (!trimmed.startsWith('M')) return false; + try { + StellarSdk.MuxedAccount.fromAddress(trimmed, '0'); + return true; + } catch { + return false; + } +} + +/** + * Check if address is a federated address (name*domain or name@domain) + */ +export function isFederatedAddress(input: string): boolean { + if (typeof input !== 'string' || !input.trim()) return false; + const trimmed = input.trim(); + // name*domain.tld (Stellar federation) OR name@domain.tld (email-style) + return ( + /^[a-zA-Z0-9._-]+\*[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(trimmed) || + /^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(trimmed) + ); +} + +/** + * Extract master account and muxed ID from a muxed address + */ +export function parseMuxedAccount( + muxedAddress: string +): { masterAccount: string; muxedId: string } | null { + try { + const muxed = StellarSdk.MuxedAccount.fromAddress(muxedAddress, '0'); + return { + masterAccount: muxed.baseAccount().accountId(), + muxedId: muxed.id(), + }; + } catch { + return null; + } +} + +// ─── Memo requirement check (SEP-29) ─────────────────────────────────────────── + +export interface MemoRequirementResult { + /** True only when the destination account has published a SEP-29 memo requirement. */ + required: boolean; + /** True when the lookup completed (successfully or with a definitive "not found"). */ + checked: boolean; + /** Present when the requirement could not be determined (network error, unsupported input). */ + error?: string; +} + +/** + * Check whether a destination requires a memo per SEP-29, by looking for a + * `config.memo_required` data entry (base64-encoded "1") on the account. + * Exchanges and custodians commonly set this flag on shared deposit addresses. + * + * Muxed accounts (M...) already encode their own memo ID and never require one. + * Federated (name*domain) and contract (C...) destinations are not currently + * checked here — federation records surface their own memo via resolveAddress(). + */ +export async function checkDestinationMemoRequirement( + destination: string, + network: NetworkName = 'testnet' +): Promise { + if (typeof destination !== 'string' || !destination.trim()) { + return { required: false, checked: false, error: 'No destination provided.' }; + } + const trimmed = destination.trim(); + + if (isValidMuxedAccount(trimmed)) { + return { required: false, checked: true }; + } + + if (!isValidEd25519PublicKey(trimmed)) { + return { required: false, checked: false, error: 'Destination is not a directly checkable account address.' }; + } + + try { + const server = getServer(network); + const account = await server.loadAccount(trimmed); + const raw = (account as unknown as { data_attr?: Record }).data_attr?.['config.memo_required']; + if (!raw) return { required: false, checked: true }; + const decoded = typeof atob === 'function' ? atob(raw) : Buffer.from(raw, 'base64').toString('utf8'); + return { required: decoded === '1', checked: true }; + } catch (error: any) { + if (error?.response?.status === 404) { + // Unfunded accounts cannot carry the SEP-29 data entry. + return { required: false, checked: true }; + } + return { + required: false, + checked: false, + error: error?.message || 'Failed to check destination memo requirement.', + }; + } +} + +/** + * Resolve a federated address to a Stellar account via Horizon federation endpoint + */ +export async function resolveFederatedAddress( + federatedAddress: string, + network: NetworkName = 'testnet' +): Promise<{ accountId: string; memoId?: string; memoType?: string } | null> { + try { + // Parse the federated address (name*domain) + const [name, domain] = federatedAddress.split('*'); + + if (!name || !domain) { + return null; + } + + // Fetch the federation record from the domain's .well-known/stellar.toml + const federationUrl = `https://${domain}/.well-known/stellar.toml`; + + let tomlData: Record = {}; + try { + const tomlResponse = await rateLimitedFetch(federationUrl); + if (!tomlResponse.ok) { + // Federation endpoint not available in current SDK version + return null; + } + + // Parse TOML (basic parsing for FEDERATION_SERVER URL) + const tomlText = await tomlResponse.text(); + const federationServerMatch = tomlText.match(/FEDERATION_SERVER\s*=\s*"([^"]+)"/); + if (federationServerMatch) { + tomlData.federationServer = federationServerMatch[1]; + } + } catch { + // Federation endpoint not available in current SDK version + return null; + } + + // Use the federation server URL if found + if (tomlData.federationServer) { + const federationEndpoint = requireAllowedEndpoint( + tomlData.federationServer, + domain, + 'federation' + ); + federationEndpoint.searchParams.append('q', federatedAddress); + federationEndpoint.searchParams.append('type', 'name'); + + const response = await rateLimitedFetch(federationEndpoint.toString()); + if (response.ok) { + return await response.json(); + } + } + + // Federation endpoint not available in current SDK version + return null; + } catch { + return null; + } +} + +/** + * Comprehensive address resolver + * Accepts: G... (Ed25519), M... (muxed), or name*domain (federated) + * Returns: master account ID, muxed ID (if applicable), and original input info + */ +export interface ResolvedAddress { + accountId: string; // The master account ID (always G...) + muxedId?: string; // Muxed ID if input was M... + originalInput: string; // Original input provided + inputType: 'ed25519' | 'muxed' | 'federated'; + federatedAddress?: string; // Original federated address if applicable + memoId?: string; // Memo ID from federation resolution + memoType?: string; // Memo type from federation resolution +} + +export async function resolveAddress( + input: string, + network: NetworkName = 'testnet' +): Promise { + if (!input || typeof input !== 'string') { + return null; + } + + const trimmedInput = input.trim(); + + // Try Ed25519 public key (G...) + if (isValidEd25519PublicKey(trimmedInput)) { + return { + accountId: trimmedInput, + originalInput: trimmedInput, + inputType: 'ed25519', + }; + } + + // Try muxed account (M...) + if (isValidMuxedAccount(trimmedInput)) { + const parsed = parseMuxedAccount(trimmedInput); + if (parsed) { + return { + accountId: parsed.masterAccount, + muxedId: parsed.muxedId, + originalInput: trimmedInput, + inputType: 'muxed', + }; + } + } + + // Try federated address (name*domain) + if (isFederatedAddress(trimmedInput)) { + const resolved = await resolveFederatedAddress(trimmedInput, network); + if (resolved?.accountId) { + return { + accountId: resolved.accountId, + originalInput: trimmedInput, + inputType: 'federated', + federatedAddress: trimmedInput, + memoId: resolved.memoId, + memoType: resolved.memoType, + }; + } + } + + return null; +} + +/** + * Validate any supported address format (legacy function name for backward compatibility) + */ +export function isValidPublicKey(key: string): boolean { + if (!key || typeof key !== 'string') return false; + const trimmed = key.trim(); + + // Check G... Ed25519 + if (isValidEd25519PublicKey(trimmed)) return true; + + // Check M... muxed + if (isValidMuxedAccount(trimmed)) return true; + + // Check name*domain federated + if (isFederatedAddress(trimmed)) return true; + + return false; +} + +export function isValidContractId(id: string): boolean { + try { + StellarSdk.Address.fromString(id); + return true; + } catch { + return false; + } +} diff --git a/src/lib/stellar/assetMarketData.ts b/src/lib/stellar/assetMarketData.ts new file mode 100644 index 00000000..643ee14e --- /dev/null +++ b/src/lib/stellar/assetMarketData.ts @@ -0,0 +1,44 @@ +import type { NetworkName } from './networks.js'; +import { fetchAssetStats, type AssetMarketData } from './assets.js'; + +/** Get mock market data for supported popular assets. */ +export async function fetchAssetMarketData( + assetCode: string, + assetIssuer: string, + network: NetworkName = 'testnet' +): Promise { + try { + const mockMarketData: Record> = { + 'USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN': { + price_usd: 1.0, + price_xlm: 8.33, + volume_24h_usd: 1500000, + market_cap_usd: 45000000000, + change_24h: 0.01, + high_24h: 1.001, + low_24h: 0.999, + }, + 'AQUA:GBNZILSTVQZ4R7IKQDGHYGY2QXL5QOFJYQMXPKWRRM5PAV7Y4M67AQUA': { + price_usd: 0.0045, + price_xlm: 0.0375, + volume_24h_usd: 125000, + change_24h: -2.5, + high_24h: 0.0048, + low_24h: 0.0043, + }, + }; + + const marketData = mockMarketData[`${assetCode}:${assetIssuer}`]; + if (!marketData) return null; + + const assetStats = await fetchAssetStats(assetCode, assetIssuer, network); + return { + asset: assetStats?.asset || { code: assetCode, issuer: assetIssuer }, + last_updated: new Date().toISOString(), + ...marketData, + } as AssetMarketData; + } catch (error) { + console.error('Error fetching asset market data:', error); + return null; + } +} \ No newline at end of file diff --git a/src/lib/stellar/assets.ts b/src/lib/stellar/assets.ts new file mode 100644 index 00000000..c25d5cb3 --- /dev/null +++ b/src/lib/stellar/assets.ts @@ -0,0 +1,550 @@ +import { getServer, type NetworkName } from './networks.js'; + +// ─── Asset Discovery & Analytics ───────────────────────────────────────────── + +export interface AssetInfo { + code: string; + issuer: string; + domain?: string; + name?: string; + description?: string; + image?: string; + conditions?: string; + is_verified?: boolean; + is_asset_anchored?: boolean; + anchor_asset_type?: string; + anchor_asset?: string; + redemption_instructions?: string; + collateral_addresses?: string[]; + collateral_address_messages?: string[]; + status?: string; + display_decimals?: number; + num_accounts?: number; + amount?: string; + flags?: { + auth_required?: boolean; + auth_revocable?: boolean; + auth_immutable?: boolean; + auth_clawback_enabled?: boolean; + }; + paging_token?: string; +} + +export interface AssetStats { + asset: AssetInfo; + num_accounts: number; + num_claimable_balances: number; + num_liquidity_pools: number; + num_contracts: number; + amount: string; + accounts: { + authorized: number; + authorized_to_maintain_liabilities: number; + unauthorized: number; + }; + balances: { + authorized: string; + authorized_to_maintain_liabilities: string; + unauthorized: string; + }; + claimable_balances_amount: string; + liquidity_pools_amount: string; + contracts_amount: string; +} + +export interface AssetMarketData { + asset: AssetInfo; + price_usd?: number; + price_xlm?: number; + volume_24h_usd?: number; + volume_24h_xlm?: number; + market_cap_usd?: number; + change_24h?: number; + high_24h?: number; + low_24h?: number; + last_updated?: string; + trading_pairs?: TradingPair[]; +} + +export interface TradingPair { + base_asset: AssetInfo; + counter_asset: AssetInfo; + price: string; + volume_24h: string; + change_24h: string; + high_24h: string; + low_24h: string; + last_trade_at: string; +} + +export interface IssuerInfo { + account_id: string; + domain?: string; + name?: string; + description?: string; + website?: string; + logo?: string; + support_email?: string; + support_url?: string; + keybase?: string; + twitter?: string; + github?: string; + telegram?: string; + linkedin?: string; + facebook?: string; + medium?: string; + reddit?: string; + is_verified?: boolean; + verification_level?: 'none' | 'domain' | 'manual' | 'full'; + assets_issued?: AssetInfo[]; + toml_url?: string; + toml_last_updated?: string; +} + +export interface AssetSearchFilters { + query?: string; + asset_type?: 'credit_alphanum4' | 'credit_alphanum12' | 'native'; + asset_issuer?: string; + verified_only?: boolean; + min_accounts?: number; + max_accounts?: number; + min_amount?: string; + max_amount?: string; + has_domain?: boolean; + order?: 'asc' | 'desc'; + sort_by?: 'code' | 'num_accounts' | 'amount' | 'created_at'; + limit?: number; + cursor?: string; +} + +export interface TrustlineRecommendation { + asset: AssetInfo; + issuer_info: IssuerInfo; + recommendation_score: number; + reasons: string[]; + risk_factors: string[]; + similar_assets?: AssetInfo[]; + market_data?: AssetMarketData; +} + +// Popular assets list (curated) +export const POPULAR_ASSETS: AssetInfo[] = [ + { + code: 'USDC', + issuer: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + domain: 'centre.io', + name: 'USD Coin', + description: 'USDC is a fully collateralized US dollar stablecoin', + is_verified: true, + is_asset_anchored: true, + anchor_asset_type: 'fiat', + anchor_asset: 'USD', + }, + { + code: 'AQUA', + issuer: 'GBNZILSTVQZ4R7IKQDGHYGY2QXL5QOFJYQMXPKWRRM5PAV7Y4M67AQUA', + domain: 'aqua.network', + name: 'Aqua Token', + description: 'AQUA is the native token of the Aqua Network', + is_verified: true, + }, + { + code: 'yXLM', + issuer: 'GARDNV3Q7YGT4AKSDF25LT32YSCCW67UUQRWQGZ2FQOTQADAAY6RQXU', + domain: 'ultrastellar.com', + name: 'yXLM', + description: 'Yield-bearing XLM token', + is_verified: true, + }, + { + code: 'MOBI', + issuer: 'GA6HCMBLTZS5VYYBCATRBRZ3BZJMAFUDKYYF6AH6MVCMGWMRDNSWJPIH', + domain: 'mobius.network', + name: 'Mobius Token', + description: 'MOBI is the native token of the Mobius Network', + is_verified: true, + }, +]; + +/** + * Fetch all assets from Horizon + */ +export async function fetchAssets( + network: NetworkName = 'testnet', + filters: AssetSearchFilters = {} +): Promise<{ records: AssetInfo[]; next?: string; prev?: string }> { + const server = getServer(network); + + let assetsCall = server.assets(); + + if (filters.asset_issuer) { + assetsCall = assetsCall.forIssuer(filters.asset_issuer); + } + + if (filters.asset_type) { + // Note: Horizon doesn't have direct asset type filtering, we'll filter client-side + } + + if (filters.order) { + assetsCall = assetsCall.order(filters.order); + } + + if (filters.limit) { + assetsCall = assetsCall.limit(filters.limit); + } + + if (filters.cursor) { + assetsCall = assetsCall.cursor(filters.cursor); + } + + const response = await assetsCall.call(); + + let assets = response.records.map( + (asset: any): AssetInfo => ({ + code: asset.asset_code, + issuer: asset.asset_issuer, + num_accounts: parseInt(asset.num_accounts), + amount: asset.amount, + flags: { + auth_required: asset.flags.auth_required, + auth_revocable: asset.flags.auth_revocable, + auth_immutable: asset.flags.auth_immutable, + auth_clawback_enabled: asset.flags.auth_clawback_enabled, + }, + paging_token: asset.paging_token, + }) + ); + + // Client-side filtering + if (filters.query) { + const query = filters.query.toLowerCase(); + assets = assets.filter( + (asset) => + asset.code.toLowerCase().includes(query) || + asset.issuer.toLowerCase().includes(query) || + asset.domain?.toLowerCase().includes(query) || + asset.name?.toLowerCase().includes(query) + ); + } + + if (filters.verified_only) { + assets = assets.filter((asset) => asset.is_verified); + } + + if (filters.min_accounts) { + assets = assets.filter((asset) => (asset.num_accounts || 0) >= filters.min_accounts!); + } + + if (filters.max_accounts) { + assets = assets.filter((asset) => (asset.num_accounts || 0) <= filters.max_accounts!); + } + + if (filters.has_domain) { + assets = assets.filter((asset) => !!asset.domain); + } + + return { + records: assets, + next: response.records[response.records.length - 1]?.paging_token, + prev: response.records[0]?.paging_token, + }; +} + +/** + * Fetch detailed asset statistics + */ +export async function fetchAssetStats( + assetCode: string, + assetIssuer: string, + network: NetworkName = 'testnet' +): Promise { + try { + const server = getServer(network); + const asset = await server.assets().forCode(assetCode).forIssuer(assetIssuer).call(); + + if (asset.records.length === 0) return null; + + const assetData = asset.records[0]; + + return { + asset: { + code: assetData.asset_code, + issuer: assetData.asset_issuer, + num_accounts: Number(assetData.num_accounts), + amount: assetData.amount, + flags: { + auth_required: assetData.flags.auth_required, + auth_revocable: assetData.flags.auth_revocable, + auth_immutable: assetData.flags.auth_immutable, + auth_clawback_enabled: assetData.flags.auth_clawback_enabled, + }, + }, + num_accounts: Number(assetData.num_accounts), + num_claimable_balances: Number(assetData.num_claimable_balances || 0), + num_liquidity_pools: Number(assetData.num_liquidity_pools || 0), + num_contracts: Number(assetData.num_contracts || 0), + amount: assetData.amount, + accounts: { + authorized: Number(assetData.accounts?.authorized || 0), + authorized_to_maintain_liabilities: Number( + assetData.accounts?.authorized_to_maintain_liabilities || 0 + ), + unauthorized: Number(assetData.accounts?.unauthorized || 0), + }, + balances: { + authorized: assetData.balances?.authorized || '0', + authorized_to_maintain_liabilities: + assetData.balances?.authorized_to_maintain_liabilities || '0', + unauthorized: assetData.balances?.unauthorized || '0', + }, + claimable_balances_amount: assetData.claimable_balances_amount || '0', + liquidity_pools_amount: assetData.liquidity_pools_amount || '0', + contracts_amount: assetData.contracts_amount || '0', + }; + } catch (error) { + console.error('Error fetching asset stats:', error); + return null; + } +} + +/** + * Fetch issuer information from stellar.toml + */ +export async function fetchIssuerInfo( + issuerAccount: string, + network: NetworkName = 'testnet' +): Promise { + try { + const server = getServer(network); + const account = await server.loadAccount(issuerAccount); + + let issuerInfo: IssuerInfo = { + account_id: issuerAccount, + verification_level: 'none', + }; + + // Check if account has a home domain + if (account.home_domain) { + issuerInfo.domain = account.home_domain; + issuerInfo.toml_url = `https://${account.home_domain}/.well-known/stellar.toml`; + + try { + // Fetch stellar.toml + const tomlResponse = await fetch(issuerInfo.toml_url); + if (tomlResponse.ok) { + const tomlText = await tomlResponse.text(); + const tomlData = parseToml(tomlText); + + // Extract organization info + if (tomlData.DOCUMENTATION) { + issuerInfo.name = tomlData.DOCUMENTATION.ORG_NAME; + issuerInfo.description = tomlData.DOCUMENTATION.ORG_DESCRIPTION; + issuerInfo.website = tomlData.DOCUMENTATION.ORG_URL; + issuerInfo.logo = tomlData.DOCUMENTATION.ORG_LOGO; + issuerInfo.support_email = tomlData.DOCUMENTATION.ORG_SUPPORT_EMAIL; + issuerInfo.support_url = tomlData.DOCUMENTATION.ORG_SUPPORT_URL; + issuerInfo.keybase = tomlData.DOCUMENTATION.ORG_KEYBASE; + issuerInfo.twitter = tomlData.DOCUMENTATION.ORG_TWITTER; + issuerInfo.github = tomlData.DOCUMENTATION.ORG_GITHUB; + } + + issuerInfo.verification_level = 'domain'; + issuerInfo.toml_last_updated = new Date().toISOString(); + } + } catch (tomlError) { + console.warn('Failed to fetch stellar.toml:', tomlError); + } + } + + return issuerInfo; + } catch (error) { + console.error('Error fetching issuer info:', error); + return { + account_id: issuerAccount, + verification_level: 'none', + }; + } +} + +/** + * Simple TOML parser for stellar.toml files + */ +function parseToml(tomlText: string): any { + const result: any = {}; + let currentSection = result; + let currentSectionName = ''; + + const lines = tomlText.split('\n'); + + for (const line of lines) { + const trimmed = line.trim(); + + // Skip empty lines and comments + if (!trimmed || trimmed.startsWith('#')) continue; + + // Section headers + if (trimmed.startsWith('[') && trimmed.endsWith(']')) { + currentSectionName = trimmed.slice(1, -1); + currentSection = result[currentSectionName] = {}; + continue; + } + + // Key-value pairs + const equalIndex = trimmed.indexOf('='); + if (equalIndex > 0) { + const key = trimmed.slice(0, equalIndex).trim(); + let value = trimmed.slice(equalIndex + 1).trim(); + + // Remove quotes + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + + currentSection[key] = value; + } + } + + return result; +} + +/** + * Get trustline recommendations for an account + */ +export async function getTrustlineRecommendations( + accountId: string, + network: NetworkName = 'testnet', + limit: number = 10 +): Promise { + try { + const server = getServer(network); + const account = await server.loadAccount(accountId); + + // Get current trustlines + const currentAssets = new Set( + account.balances + .filter((balance) => balance.asset_type !== 'native') + .map((balance) => { + const assetBalance = balance as unknown as { asset_code: string; asset_issuer: string }; + return `${assetBalance.asset_code}:${assetBalance.asset_issuer}`; + }) + ); + + // Fetch popular assets + const { records: popularAssets } = await fetchAssets(network, { + limit: 50, + order: 'desc', + }); + + const recommendations: TrustlineRecommendation[] = []; + + for (const asset of popularAssets) { + const assetKey = `${asset.code}:${asset.issuer}`; + + // Skip if already trusted + if (currentAssets.has(assetKey)) continue; + + // Get issuer info + const issuerInfo = await fetchIssuerInfo(asset.issuer, network); + + // Calculate recommendation score + let score = 0; + const reasons: string[] = []; + const riskFactors: string[] = []; + + // Scoring factors + if (asset.num_accounts && asset.num_accounts > 1000) { + score += 30; + reasons.push(`Popular asset with ${asset.num_accounts.toLocaleString()} holders`); + } + + if (issuerInfo.verification_level === 'domain') { + score += 25; + reasons.push('Verified issuer with domain'); + } + + if (asset.is_verified) { + score += 20; + reasons.push('Verified asset'); + } + + if (asset.flags?.auth_required) { + score -= 10; + riskFactors.push('Requires authorization from issuer'); + } + + if (asset.flags?.auth_revocable) { + score -= 15; + riskFactors.push('Issuer can revoke authorization'); + } + + if (asset.flags?.auth_clawback_enabled) { + score -= 20; + riskFactors.push('Issuer can clawback funds'); + } + + if (!issuerInfo.domain) { + score -= 15; + riskFactors.push('No domain verification'); + } + + // Only recommend assets with positive scores + if (score > 0) { + recommendations.push({ + asset, + issuer_info: issuerInfo, + recommendation_score: score, + reasons, + risk_factors: riskFactors, + }); + } + + if (recommendations.length >= limit) break; + } + + // Sort by recommendation score + return recommendations.sort((a, b) => b.recommendation_score - a.recommendation_score); + } catch (error) { + console.error('Error getting trustline recommendations:', error); + return []; + } +} + +/** + * Search assets with advanced filtering + */ +export async function searchAssets( + query: string, + network: NetworkName = 'testnet', + filters: AssetSearchFilters = {} +): Promise { + const searchFilters: AssetSearchFilters = { + ...filters, + query: query.trim(), + limit: filters.limit || 20, + }; + + const { records } = await fetchAssets(network, searchFilters); + + // Enhance with issuer info for top results + const enhancedAssets = await Promise.all( + records.slice(0, 10).map(async (asset) => { + try { + const issuerInfo = await fetchIssuerInfo(asset.issuer, network); + return { + ...asset, + domain: issuerInfo.domain, + name: issuerInfo.name, + description: issuerInfo.description, + is_verified: issuerInfo.verification_level !== 'none', + }; + } catch { + return asset; + } + }) + ); + + return [...enhancedAssets, ...records.slice(10)]; +} diff --git a/src/lib/stellar/claimableBalances.ts b/src/lib/stellar/claimableBalances.ts new file mode 100644 index 00000000..bbcd72bf --- /dev/null +++ b/src/lib/stellar/claimableBalances.ts @@ -0,0 +1,335 @@ +import { NETWORKS, rateLimitedFetch, stellarCache, type NetworkName } from './networks.js'; +import { TTL } from '../cache.js'; +import { throwIfAborted, type HorizonRequestOptions } from './horizon.js'; + +// ─── Claimable Balances ─────────────────────────────────────────────────────── + +export interface ClaimableBalanceRecord { + id: string; + asset: string; + amount: string; + sponsor: string; + last_modified_ledger: number; + claimants: Array<{ + destination: string; + predicate: Record; + }>; +} + +/** Human-readable summary of a claimant predicate. */ +export function formatClaimPredicate(predicate: Record): string { + if (!predicate || Object.keys(predicate).length === 0) return 'Unconditional'; + if ('unconditional' in predicate) return 'Unconditional'; + if ('abs_before' in predicate) return `Before ${predicate.abs_before}`; + if ('abs_after' in predicate) return `After ${predicate.abs_after}`; + if ('rel_before' in predicate) return `Within ${predicate.rel_before}s of claim`; + if ('and' in predicate) { + const parts = (predicate.and as Record[]).map(formatClaimPredicate); + return parts.join(' AND '); + } + if ('or' in predicate) { + const parts = (predicate.or as Record[]).map(formatClaimPredicate); + return parts.join(' OR '); + } + if ('not' in predicate) + return `NOT (${formatClaimPredicate(predicate.not as Record)})`; + return JSON.stringify(predicate); +} + +export async function fetchClaimableBalances( + publicKey: string, + network: NetworkName = 'testnet', + options: HorizonRequestOptions = {} +): Promise { + throwIfAborted(options.signal); + + const cacheKey = `claimable:${publicKey}:${network}`; + const cached = stellarCache.get(cacheKey); + if (cached) return cached; + + const config = NETWORKS[network]; + const url = `${config.horizonUrl}/claimable_balances?claimant=${encodeURIComponent(publicKey)}&limit=50`; + // This path goes through `fetch`, so the signal cancels the request on the wire. + const response = await rateLimitedFetch( + url, + options.signal ? { signal: options.signal } : undefined, + 'medium', + config.customHeaders + ); + + if (!response.ok) throw new Error(`Horizon error ${response.status}`); + + const data = await response.json(); + const records: ClaimableBalanceRecord[] = data._embedded?.records ?? []; + stellarCache.set(cacheKey, records, TTL.ACCOUNT, ['claimable', publicKey]); + return records; +} + +// ─── Claimable Balance predicate explanation ─────────────────────────────────── + +/** + * Structured, human-readable explanation of a claimable-balance claimant + * predicate. Used by the claimable-balance workspace to explain *when* and + * *whether* a balance can be claimed. + */ +export interface PredicateExplanation { + /** Normalized predicate kind. */ + kind: 'unconditional' | 'abs_before' | 'abs_after' | 'rel_before' | 'and' | 'or' | 'not' | 'unknown'; + /** Short human-readable summary. */ + summary: string; + /** Whether the predicate currently permits a claim (best-effort, null = unknown). */ + claimableNow: boolean | null; + /** Epoch ms at/after which the balance becomes claimable, if determinable. */ + claimableAt: number | null; + /** Sub-explanations for `and` / `or` / `not` predicates. */ + children?: PredicateExplanation[]; +} + +function toEpochMs(value: unknown): number | null { + if (typeof value === 'number') return value * 1000; // Horizon timestamps are seconds + if (typeof value === 'string') { + const n = Number(value); + if (!Number.isNaN(n) && value.trim() !== '') return n * 1000; + const t = Date.parse(value); + if (!Number.isNaN(t)) return t; + } + return null; +} + +/** Combine child `claimableNow` results for an AND predicate. */ +function combineAnd(children: PredicateExplanation[]): boolean | null { + if (children.length === 0) return null; + if (children.some((c) => c.claimableNow === false)) return false; + if (children.some((c) => c.claimableNow === null)) return null; + return true; +} + +/** Combine child `claimableNow` results for an OR predicate. */ +function combineOr(children: PredicateExplanation[]): boolean | null { + if (children.length === 0) return null; + if (children.some((c) => c.claimableNow === true)) return true; + if (children.some((c) => c.claimableNow === null)) return null; + return false; +} + +/** + * Explain a claimant predicate deterministically. + * + * @param predicate - Raw Horizon predicate object (or already a string/object) + * @param now - Reference timestamp in ms (defaults to `Date.now()`); injectable + * for tests and deterministic rendering. + * @returns {PredicateExplanation} + */ +export function explainClaimPredicate(predicate: unknown, now: number = Date.now()): PredicateExplanation { + if (!predicate || typeof predicate !== 'object' || Array.isArray(predicate) || Object.keys(predicate).length === 0) { + return { + kind: 'unconditional', + summary: 'Unconditional — claimable by the recipient at any time', + claimableNow: true, + claimableAt: null, + }; + } + + const p = predicate as Record; + + if ('unconditional' in p) { + return { + kind: 'unconditional', + summary: 'Unconditional — claimable by the recipient at any time', + claimableNow: true, + claimableAt: null, + }; + } + + if ('abs_before' in p) { + const ms = toEpochMs(p.abs_before); + if (ms == null) { + return { kind: 'abs_before', summary: `Before ${String(p.abs_before)}`, claimableNow: null, claimableAt: null }; + } + return { + kind: 'abs_before', + summary: `Claimable before ${new Date(ms).toUTCString()}`, + claimableNow: now < ms, + claimableAt: null, + }; + } + + if ('abs_after' in p) { + const ms = toEpochMs(p.abs_after); + if (ms == null) { + return { kind: 'abs_after', summary: `After ${String(p.abs_after)}`, claimableNow: null, claimableAt: null }; + } + return { + kind: 'abs_after', + summary: `Claimable after ${new Date(ms).toUTCString()}`, + claimableNow: now >= ms, + claimableAt: ms, + }; + } + + if ('rel_before' in p) { + const secs = Number(p.rel_before); + if (Number.isNaN(secs)) { + return { kind: 'rel_before', summary: `Within ${String(p.rel_before)}s of claim`, claimableNow: null, claimableAt: null }; + } + return { + kind: 'rel_before', + summary: `Claimable within ${secs}s of the balance's creation ledger`, + claimableNow: null, + claimableAt: null, + }; + } + + if ('and' in p) { + const children = Array.isArray(p.and) ? (p.and as unknown[]).map((c) => explainClaimPredicate(c, now)) : []; + return { + kind: 'and', + summary: `All of: ${children.map((c) => c.summary).join(' AND ')}`, + claimableNow: combineAnd(children), + claimableAt: null, + children, + }; + } + + if ('or' in p) { + const children = Array.isArray(p.or) ? (p.or as unknown[]).map((c) => explainClaimPredicate(c, now)) : []; + return { + kind: 'or', + summary: `Any of: ${children.map((c) => c.summary).join(' OR ')}`, + claimableNow: combineOr(children), + claimableAt: null, + children, + }; + } + + if ('not' in p) { + const child = explainClaimPredicate(p.not, now); + return { + kind: 'not', + summary: `NOT (${child.summary})`, + claimableNow: child.claimableNow === null ? null : !child.claimableNow, + claimableAt: null, + children: [child], + }; + } + + return { + kind: 'unknown', + summary: JSON.stringify(predicate), + claimableNow: null, + claimableAt: null, + }; +} + +// ─── Claimable Balance predicate builder ─────────────────────────────────────── + +/** + * Declarative spec for building a Stellar claimable-balance claimant predicate. + * Kept UI-agnostic and pure so it can be unit-tested without the Stellar SDK. + */ +export type PredicateSpec = + | { type: 'unconditional' } + | { type: 'before'; date: string | Date } + | { type: 'after'; date: string | Date } + | { type: 'relative'; seconds: number } + | { type: 'and'; predicates: PredicateSpec[] } + | { type: 'or'; predicates: PredicateSpec[] } + | { type: 'not'; predicate: PredicateSpec }; + +function toISODate(value: string | Date): string | null { + if (value instanceof Date) return value.toISOString(); + if (typeof value === 'string') { + const t = Date.parse(value); + if (!Number.isNaN(t)) return new Date(t).toISOString(); + } + return null; +} + +/** + * Build a Stellar claimant predicate object from a declarative {@link PredicateSpec}. + * + * @param spec - The predicate specification + * @returns A plain predicate object consumable by `StellarSdk.Claimant` + * @throws {TypeError} If the spec is invalid (bad date, non-positive relative + * time, too few operands, or unknown type) + */ +export function buildClaimPredicate(spec: PredicateSpec): Record { + if (!spec || typeof spec !== 'object') { + throw new TypeError('buildClaimPredicate: spec must be an object'); + } + switch (spec.type) { + case 'unconditional': + return { unconditional: null }; + case 'before': { + const iso = toISODate(spec.date); + if (!iso) throw new TypeError('buildClaimPredicate: "before" requires a valid date'); + return { abs_before: iso }; + } + case 'after': { + const iso = toISODate(spec.date); + if (!iso) throw new TypeError('buildClaimPredicate: "after" requires a valid date'); + return { abs_after: iso }; + } + case 'relative': { + const secs = Number(spec.seconds); + if (!Number.isFinite(secs) || secs <= 0) { + throw new TypeError('buildClaimPredicate: "relative" requires a positive number of seconds'); + } + return { rel_before: Math.floor(secs) }; + } + case 'and': + case 'or': { + const list = spec.predicates; + if (!Array.isArray(list) || list.length < 2) { + throw new TypeError(`buildClaimPredicate: "${spec.type}" requires at least two predicates`); + } + return { [spec.type]: list.map(buildClaimPredicate) }; + } + case 'not': { + if (!spec.predicate) throw new TypeError('buildClaimPredicate: "not" requires a predicate'); + return { not: buildClaimPredicate(spec.predicate) }; + } + default: + throw new TypeError(`buildClaimPredicate: unknown predicate type "${(spec as { type?: string }).type}"`); + } +} + +// ─── Claimable Balance inspection ────────────────────────────────────────────── + +/** + * Fetch a single claimable balance by its id (Horizon `GET /claimable_balances/:id`). + * + * Used by the workspace "inspect" view to show full claimant/predicate detail. + * + * @param balanceId - Horizon claimable-balance id (non-empty string) + * @param network - Target network + * @returns {Promise} + * @throws {TypeError} If `balanceId` is not a non-empty string + * @throws {Error} If the network is unsupported or Horizon returns an error + * (404 → "not found") + */ +export async function fetchClaimableBalanceById( + balanceId: string, + network: NetworkName = 'testnet' +): Promise { + if (typeof balanceId !== 'string' || balanceId.trim().length === 0) { + throw new TypeError('fetchClaimableBalanceById: balanceId must be a non-empty string'); + } + const config = NETWORKS[network]; + if (!config || !config.horizonUrl) { + throw new Error(`fetchClaimableBalanceById: unsupported network "${network}"`); + } + + const url = `${config.horizonUrl}/claimable_balances/${encodeURIComponent(balanceId)}`; + const response = await rateLimitedFetch(url, undefined, 'medium', config.customHeaders); + + if (response.status === 404) { + throw new Error(`Claimable balance ${balanceId} not found`); + } + if (!response.ok) { + throw new Error(`Horizon error ${response.status} while fetching claimable balance`); + } + + const data = await response.json(); + return data as ClaimableBalanceRecord; +} diff --git a/src/lib/stellar/default.ts b/src/lib/stellar/default.ts new file mode 100644 index 00000000..48bb4d35 --- /dev/null +++ b/src/lib/stellar/default.ts @@ -0,0 +1,125 @@ +import * as api from './index.js'; + +const { + NETWORKS, + getNetworkDetails, + updateCustomNetworkConfig, + switchToCustomProfile, + loadCustomNetworkProfiles, + getServer, + getSorobanServer, + fetchAccount, + fetchTransactions, + fetchOperations, + fetchAccountOffers, + getOperationLabel, + fetchAccountCreationDate, + streamLedgers, + fetchNetworkStats, + fetchXLMPrice, + fetchAssetPrice, + fundTestnetAccount, + fetchContractInfo, + simulateContractCall, + invokeContract, + isValidPublicKey, + isValidEd25519PublicKey, + isValidMuxedAccount, + isFederatedAddress, + parseMuxedAccount, + checkDestinationMemoRequirement, + resolveFederatedAddress, + resolveAddress, + isValidContractId, + formatXLM, + shortAddress, + buildTransaction, + simulateTransaction, + runAdvancedTransactionSimulation, + exportTransactionXDR, + fetchPaymentPaths, + fetchLiquidityPools, + fetchLiquidityPoolsByAssetPair, + fetchLiquidityPoolById, + fetchLiquidityPoolOperations, + fetchAccountLiquidityPoolPositions, + fetchAccountLiquidityPoolHistory, + fetchAssets, + fetchAssetStats, + fetchIssuerInfo, + getTrustlineRecommendations, + searchAssets, + fetchAssetMarketData, + calculateAccountReserves, + clearCache, + getCacheStats, + formatInstructions, + formatBytes, + formatStroops, + importBatchXdr, + simulateBatchXdr, + validateXdrForBroadcast, + StellarSdk, +} = api; + +export default { + NETWORKS, + getNetworkDetails, + updateCustomNetworkConfig, + switchToCustomProfile, + loadCustomNetworkProfiles, + getServer, + getSorobanServer, + fetchAccount, + fetchTransactions, + fetchOperations, + fetchAccountOffers, + getOperationLabel, + fetchAccountCreationDate, + streamLedgers, + fetchNetworkStats, + fetchXLMPrice, + fetchAssetPrice, + fundTestnetAccount, + fetchContractInfo, + simulateContractCall, + invokeContract, + isValidPublicKey, + isValidEd25519PublicKey, + isValidMuxedAccount, + isFederatedAddress, + parseMuxedAccount, + checkDestinationMemoRequirement, + resolveFederatedAddress, + resolveAddress, + isValidContractId, + formatXLM, + shortAddress, + buildTransaction, + simulateTransaction, + runAdvancedTransactionSimulation, + exportTransactionXDR, + fetchPaymentPaths, + fetchLiquidityPools, + fetchLiquidityPoolsByAssetPair, + fetchLiquidityPoolById, + fetchLiquidityPoolOperations, + fetchAccountLiquidityPoolPositions, + fetchAccountLiquidityPoolHistory, + fetchAssets, + fetchAssetStats, + fetchIssuerInfo, + getTrustlineRecommendations, + searchAssets, + fetchAssetMarketData, + calculateAccountReserves, + clearCache, + getCacheStats, + formatInstructions, + formatBytes, + formatStroops, + importBatchXdr, + simulateBatchXdr, + validateXdrForBroadcast, + StellarSdk, +}; diff --git a/src/lib/stellar/faucet.ts b/src/lib/stellar/faucet.ts new file mode 100644 index 00000000..93210d44 --- /dev/null +++ b/src/lib/stellar/faucet.ts @@ -0,0 +1,15 @@ +import { NETWORKS } from './networks.js'; + +// ─── Faucet ─────────────────────────────────────────────────────────────────── + +export async function fundTestnetAccount(publicKey: string): Promise { + // Guard: faucet requires network writes and cannot be queued — block offline. + const { isWriteSafe } = await import('../offlineReadOnly'); + if (!isWriteSafe('faucet funding')) { + const { OfflineWriteError } = await import('../offlineReadOnly'); + throw new OfflineWriteError('faucet funding'); + } + const res = await fetch(`${NETWORKS.testnet.faucetUrl}?addr=${publicKey}`); + if (!res.ok) throw new Error('Faucet request failed'); + return res.json(); +} diff --git a/src/lib/stellar/formatters.ts b/src/lib/stellar/formatters.ts new file mode 100644 index 00000000..fd82f24e --- /dev/null +++ b/src/lib/stellar/formatters.ts @@ -0,0 +1,58 @@ +import { stellarCache } from './networks.js'; + +// ─── Formatters ─────────────────────────────────────────────────────────────── + +export function formatXLM(amount: string | number): string { + return parseFloat(String(amount)).toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 7, + }); +} + +export function shortAddress(addr: string | null | undefined, chars = 6): string { + if (!addr) return ''; + return `${addr.slice(0, chars)}…${addr.slice(-chars)}`; +} + + + +/** + * Clear cache for specific pattern + * @param {string} pattern - Key pattern to clear + */ +export function clearCache(pattern: string | null = null) { + if (pattern) { + stellarCache.invalidatePrefix(pattern); + } else { + stellarCache.clear(); + } +} + +/** + * Get cache statistics + * @returns {object} Cache stats + */ +export function getCacheStats() { + return stellarCache.getStats(); +} + + + +export function formatInstructions(instructions: number): string { + if (instructions < 1000) return `${instructions}`; + if (instructions < 1000000) return `${(instructions / 1000).toFixed(2)}K`; + return `${(instructions / 1000000).toFixed(2)}M`; +} + +export function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; +} + +export function formatStroops(stroops: unknown): string { + const num = typeof stroops === 'number' ? stroops : parseInt(String(stroops), 10); + if (isNaN(num)) return '—'; + const xlm = (num / 10000000).toFixed(7); + return `${xlm} XLM (${num.toLocaleString('en-US')} stroops)`; +} diff --git a/src/lib/stellar/horizon.ts b/src/lib/stellar/horizon.ts new file mode 100644 index 00000000..a107541f --- /dev/null +++ b/src/lib/stellar/horizon.ts @@ -0,0 +1,306 @@ +import * as StellarSdk from '@stellar/stellar-sdk'; +import { TTL } from '../cache.js'; +import { getCircuitBreaker } from '../errorHandling/CircuitBreaker'; +import { getServer, stellarCache, type NetworkName } from './networks.js'; + +// ─── Cancellation ───────────────────────────────────────────────────────────── + +/** + * Per-request options accepted by the account-scoped Horizon readers. + * + * `signal` lets a caller abandon a read when the user switches account or network + * (Issue #745). Every reader below is backwards compatible: omit the argument and + * behaviour is unchanged. + */ +export interface HorizonRequestOptions { + signal?: AbortSignal; +} + +/** DOM-compatible abort error, usable where `DOMException` is unavailable. */ +function createAbortError(): Error { + if (typeof DOMException === 'function') { + return new DOMException('The operation was aborted.', 'AbortError'); + } + const error = new Error('The operation was aborted.'); + error.name = 'AbortError'; + return error; +} + +/** Rejects immediately when `signal` is already aborted. */ +export function throwIfAborted(signal?: AbortSignal): void { + if (signal?.aborted) throw createAbortError(); +} + +/** + * Rejects as soon as `signal` aborts, even when `promise` cannot itself be + * cancelled. The Stellar SDK's `CallBuilder`/`loadAccount` do not accept an + * `AbortSignal`, so the underlying HTTP request may still complete — but its result + * is detached from the caller and can no longer overwrite current state. + */ +function withAbort(promise: Promise, signal?: AbortSignal): Promise { + if (!signal) return promise; + if (signal.aborted) return Promise.reject(createAbortError()); + + return new Promise((resolve, reject) => { + const onAbort = () => reject(createAbortError()); + signal.addEventListener('abort', onAbort, { once: true }); + promise.then(resolve, reject).finally(() => { + signal.removeEventListener('abort', onAbort); + }); + }); +} + +// ─── Account ────────────────────────────────────────────────────────────────── + +export async function fetchAccount( + publicKey: string, + network: NetworkName = 'testnet', + options: HorizonRequestOptions = {} +): Promise { + throwIfAborted(options.signal); + + const cacheKey = `account:${publicKey}:${network}`; + const cached = stellarCache.get(cacheKey); + if (cached) return cached; + + const breaker = getCircuitBreaker(`horizon:${network}`, { failureThreshold: 5, timeout: 30_000 }); + const server = getServer(network); + const account = await withAbort( + breaker.execute(() => server.loadAccount(publicKey)), + options.signal + ); + stellarCache.set(cacheKey, account, TTL.ACCOUNT, ['account', publicKey]); + return account; +} + +// ─── Transactions & Operations ──────────────────────────────────────────────── + +export async function fetchTransactions( + publicKey: string, + network: NetworkName = 'testnet', + limit = 20, + cursor: string | null = null, + options: HorizonRequestOptions = {} +): Promise<{ + records: StellarSdk.Horizon.ServerApi.TransactionRecord[]; + nextCursor: string | null; + hasMore: boolean; +}> { + throwIfAborted(options.signal); + + const cacheKey = `transactions:${publicKey}:${network}:${limit}:${cursor || 'null'}`; + const cached = stellarCache.get(cacheKey); + if (cached) return cached; + + const server = getServer(network); + const request = server.transactions().forAccount(publicKey).order('desc').limit(limit); + + if (cursor) request.cursor(cursor); + + const txs = await withAbort(request.call(), options.signal); + const records = txs.records || []; + const nextCursor = records.length > 0 ? records[records.length - 1].paging_token : null; + + const result = { + records, + nextCursor, + hasMore: records.length === limit && !!nextCursor, + }; + stellarCache.set(cacheKey, result, TTL.TRANSACTIONS, ['transactions', publicKey]); + return result; +} + +export async function fetchOperations( + publicKey: string, + network: NetworkName = 'testnet', + limit = 20, + cursor: string | null = null, + options: HorizonRequestOptions = {} +): Promise<{ + records: StellarSdk.Horizon.ServerApi.OperationRecord[]; + nextCursor: string | null; + hasMore: boolean; +}> { + throwIfAborted(options.signal); + + const cacheKey = `operations:${publicKey}:${network}:${limit}:${cursor || 'null'}`; + const cached = stellarCache.get(cacheKey); + if (cached) return cached; + + const server = getServer(network); + const request = server.operations().forAccount(publicKey).order('desc').limit(limit); + + if (cursor) request.cursor(cursor); + + const ops = await withAbort(request.call(), options.signal); + const records = ops.records || []; + const nextCursor = records.length > 0 ? records[records.length - 1].paging_token : null; + + const result = { + records, + nextCursor, + hasMore: records.length === limit && !!nextCursor, + }; + stellarCache.set(cacheKey, result, TTL.OPERATIONS, ['operations', publicKey]); + return result; +} + +export async function fetchAccountOffers( + publicKey: string, + network: NetworkName = 'testnet', + options: HorizonRequestOptions = {} +): Promise { + throwIfAborted(options.signal); + + const cacheKey = `offers:${publicKey}:${network}`; + const cached = stellarCache.get(cacheKey); + if (cached) return cached; + + const server = getServer(network); + const offers = await withAbort(server.offers().forAccount(publicKey).call(), options.signal); + const records = offers.records || []; + stellarCache.set(cacheKey, records, TTL.ACCOUNT, ['offers', publicKey]); + return records; +} + +export async function fetchTransactionDetails( + hash: string, + network: NetworkName = 'testnet' +): Promise<{ + transaction: StellarSdk.Horizon.ServerApi.TransactionRecord; + operations: StellarSdk.Horizon.ServerApi.OperationRecord[]; +}> { + const cacheKey = `transaction-details:${hash}:${network}`; + const cached = stellarCache.get(cacheKey); + if (cached) return cached; + + const server = getServer(network); + const [transaction, opsResponse] = await Promise.all([ + server.transactions().transaction(hash).call(), + server.operations().forTransaction(hash).call(), + ]); + + const result = { + transaction, + operations: opsResponse.records || [], + }; + + stellarCache.set(cacheKey, result, TTL.TRANSACTIONS, ['transactions', hash]); + return result; +} + +// ─── Operation labels ─────────────────────────────────────────────────────────── + +export const OPERATION_LABELS: Record = { + create_account: 'Create Account', + payment: 'Payment', + path_payment_strict_send: 'Path Payment (Send)', + path_payment_strict_receive: 'Path Payment (Receive)', + manage_buy_offer: 'Buy Offer', + manage_sell_offer: 'Sell Offer', + create_passive_sell_offer: 'Create Passive Sell Offer', + set_options: 'Set Options', + change_trust: 'Change Trust', + allow_trust: 'Allow Trust', + account_merge: 'Account Merge', + manage_data: 'Manage Data', + bump_sequence: 'Bump Sequence', + create_claimable_balance: 'Create Claimable Balance', + claim_claimable_balance: 'Claim Claimable Balance', + begin_sponsoring_future_reserves: 'Begin Sponsoring Future Reserves', + end_sponsoring_future_reserves: 'End Sponsoring Future Reserves', + revoke_sponsorship: 'Revoke Sponsorship', + clawback: 'Clawback', + clawback_claimable_balance: 'Clawback Claimable Balance', + set_trust_line_flags: 'Set Trustline Flags', + liquidity_pool_deposit: 'Liquidity Pool Deposit', + liquidity_pool_withdraw: 'Liquidity Pool Withdraw', + invoke_host_function: 'Contract Call', + extend_footprint_ttl: 'Extend Footprint TTL', + restore_footprint: 'Restore Footprint', +}; + +function titleCaseLabel(value: string): string { + return value.replace(/_/g, ' ').replace(/\b\w/g, (char) => char.toUpperCase()); +} + +export function getOperationLabel(type: string): string { + return OPERATION_LABELS[type] || titleCaseLabel(type); +} + +export async function fetchAccountCreationDate( + publicKey: string, + network: NetworkName = 'testnet', + options: HorizonRequestOptions = {} +): Promise { + throwIfAborted(options.signal); + + const cacheKey = `creation-date:${publicKey}:${network}`; + const cached = stellarCache.get(cacheKey); + if (cached) return cached; + + const server = getServer(network); + + try { + const ops = await withAbort( + server.operations().forAccount(publicKey).order('asc').limit(1).call(), + options.signal + ); + + const operation = ops.records[0]; + const date = operation?.type === 'create_account' ? operation.created_at || null : null; + + if (date) { + stellarCache.set(cacheKey, date, TTL.ACCOUNT, ['account', publicKey]); + } + return date; + } catch (error) { + // A creation date is best-effort, but an abort is not a "no date found" — + // swallowing it would let a cancelled read resolve and clear current state. + if ((error as Error | undefined)?.name === 'AbortError') throw error; + return null; + } +} + +export function streamLedgers( + callback: (_ledger: StellarSdk.Horizon.ServerApi.LedgerRecord) => void, + network: NetworkName = 'testnet' +): () => void { + const server = getServer(network); + return server + .ledgers() + .cursor('now') + .stream({ + onmessage: (page) => { + if (page?.records?.length) { + page.records.forEach((ledger) => callback(ledger)); + } + }, + onerror: (error) => console.error('Ledger stream error:', error), + }); + } + +// ─── Network stats ──────────────────────────────────────────────────────────── + +export interface NetworkStats { + latestLedger: StellarSdk.Horizon.ServerApi.LedgerRecord; + feeStats: StellarSdk.Horizon.HorizonApi.FeeStatsResponse; +} + +export async function fetchNetworkStats(network: NetworkName = 'testnet'): Promise { + const cacheKey = `network-stats:${network}`; + const cached = stellarCache.get(cacheKey); + if (cached) return cached; + + const breaker = getCircuitBreaker(`horizon:${network}`, { failureThreshold: 5, timeout: 30_000 }); + const server = getServer(network); + const [ledger, feeStats] = await breaker.execute(() => + Promise.all([server.ledgers().order('desc').limit(1).call(), server.feeStats()]) + ); + const result = { + latestLedger: ledger.records[0], + feeStats, + }; + stellarCache.set(cacheKey, result, TTL.LEDGER, ['network-stats', network]); + return result; +} diff --git a/src/lib/stellar/horizonReadSource.ts b/src/lib/stellar/horizonReadSource.ts index 7380c022..2a3b9d96 100644 --- a/src/lib/stellar/horizonReadSource.ts +++ b/src/lib/stellar/horizonReadSource.ts @@ -1,5 +1,5 @@ import * as StellarSdk from '@stellar/stellar-sdk'; -import { getServer, NetworkName } from '../stellar'; +import { getServer, NetworkName } from './networks.js'; import { StellarReadSource, GetLedgersParams, diff --git a/src/lib/stellar/index.ts b/src/lib/stellar/index.ts index e75d75af..c49919cf 100644 --- a/src/lib/stellar/index.ts +++ b/src/lib/stellar/index.ts @@ -1,61 +1,23 @@ -import { NetworkName, getNetworkDetails } from '../stellar'; -import { StellarReadSource } from './types'; -import { HorizonReadSource } from './horizonReadSource'; -import { RpcReadSource } from './rpcReadSource'; -import { RpcFirstReadSource } from './rpcFirstReadSource'; -import { isOnline } from '../offlineReadOnly'; - -export * from './types'; -export * from './horizonReadSource'; -export * from './rpcReadSource'; -export * from './rpcFirstReadSource'; - -export type ReadSourceMode = 'rpc-first' | 'rpc-only' | 'horizon-only'; - -/** - * Factory function to instantiate a Stellar read source for a given network profile. - */ -export function getStellarReadSource( - network: NetworkName = 'testnet', - mode: ReadSourceMode = 'rpc-first' -): StellarReadSource { - switch (mode) { - case 'rpc-only': - return new RpcReadSource(network); - case 'horizon-only': - return new HorizonReadSource(network); - case 'rpc-first': - default: - return new RpcFirstReadSource(network); - } -} - -/** - * Helper to evaluate data source capabilities and connectivity for a network profile. - */ -export function evaluateReadSourceCapabilities(network: NetworkName = 'testnet') { - const config = getNetworkDetails(network); - const online = isOnline(); - const hasHorizon = Boolean(config?.horizonUrl); - const hasRpc = Boolean(config?.sorobanUrl); - - const capabilities = { - ledgers: online && (hasHorizon || hasRpc), - transactions: online && (hasHorizon || hasRpc), - events: online && (hasHorizon || hasRpc), - accountOffers: online && hasHorizon, - fullHistory: online && hasHorizon, - defaultReadSource: hasRpc ? ('rpc' as const) : ('horizon' as const), - retentionLimitLedgers: hasRpc ? 100_000 : undefined, - isRpcOnly: hasRpc && !hasHorizon, - isHorizonOnly: hasHorizon && !hasRpc, - }; - - return { - network, - online, - hasHorizon, - hasRpc, - capabilities, - }; -} +import * as StellarSdk from '@stellar/stellar-sdk'; +export * from './networks.js'; +export * from './horizon.js'; +export * from './reserves.js'; +export * from './pricing.js'; +export * from './faucet.js'; +export * from './soroban.js'; +export * from './addresses.js'; +export * from './claimableBalances.js'; +export * from './formatters.js'; +export * from './transactionBuilder.js'; +export * from './simulation.js'; +export * from './paths.js'; +export * from './liquidityPools.js'; +export * from './assets.js'; +export * from './assetMarketData.js'; +export * from './readSources.js'; +export { importBatchXdr, simulateBatchXdr, validateXdrForBroadcast } from '../batchXdrImport.js'; +export type { BatchXdrImportResult, BatchXdrImportOptions, ValidationReportItem, XdrImportItem } from '../batchXdrImport.js'; +export { StellarSdk }; +export type AccountDetails = StellarSdk.Horizon.AccountResponse; +export { fetchAccount as fetchAccountDetails } from './horizon.js'; +export { isValidEd25519PublicKey as isPublicKey } from './addresses.js'; diff --git a/src/lib/stellar/liquidityPools.ts b/src/lib/stellar/liquidityPools.ts new file mode 100644 index 00000000..10168a77 --- /dev/null +++ b/src/lib/stellar/liquidityPools.ts @@ -0,0 +1,155 @@ +import * as StellarSdk from '@stellar/stellar-sdk'; +import { NETWORKS, getServer, type NetworkName } from './networks.js'; +import { fetchAccount } from './horizon.js'; +import type { PathAsset } from './paths.js'; + +// ─── Liquidity pools ───────────────────────────────────────────────────────── + +export interface LiquidityPoolReserve { + asset: string; + amount: string; +} + +export interface LiquidityPoolRecord { + id: string; + paging_token?: string; + fee_bp?: number; + type?: string; + total_trustlines?: string | number; + total_shares?: string; + reserves?: LiquidityPoolReserve[]; +} + +export interface LiquidityPoolPosition { + poolId: string; + balance: string; + limit?: string; + sharePercent: number; + pool: LiquidityPoolRecord | null; +} + +interface AccountLiquidityPoolBalance { + asset_type: 'liquidity_pool_shares'; + liquidity_pool_id: string; + balance: string; + limit?: string; +} + +function horizonUrl(network: NetworkName, path: string): string { + return `${NETWORKS[network]?.horizonUrl || NETWORKS.testnet.horizonUrl}${path}`; +} + +async function horizonJson(network: NetworkName, path: string): Promise { + const response = await fetch(horizonUrl(network, path)); + if (!response.ok) throw new Error(`Horizon request failed: ${response.status}`); + return response.json() as Promise; +} + +function poolAssetString(asset: PathAsset | string): string { + if (typeof asset === 'string') return asset === 'XLM' ? 'native' : asset; + if (asset.type === 'native') return 'native'; + return `${asset.code}:${asset.issuer}`; +} + +function poolRecords(payload: { + _embedded?: { records?: LiquidityPoolRecord[] }; + records?: LiquidityPoolRecord[]; +}): LiquidityPoolRecord[] { + return payload._embedded?.records ?? payload.records ?? []; +} + +export async function fetchLiquidityPools( + network: NetworkName = 'testnet', + limit = 50, + reserves?: Array +): Promise { + const params = new URLSearchParams({ limit: String(limit) }); + if (reserves?.length) { + params.set('reserves', reserves.map(poolAssetString).join(',')); + } + + const data = await horizonJson<{ _embedded?: { records?: LiquidityPoolRecord[] } }>( + network, + `/liquidity_pools?${params.toString()}` + ); + return poolRecords(data); +} + +export function fetchLiquidityPoolsByAssetPair( + assetA: PathAsset | string, + assetB: PathAsset | string, + network: NetworkName = 'testnet', + limit = 50 +): Promise { + return fetchLiquidityPools(network, limit, [assetA, assetB]); +} + +export function fetchLiquidityPoolById( + poolId: string, + network: NetworkName = 'testnet' +): Promise { + return horizonJson( + network, + `/liquidity_pools/${encodeURIComponent(poolId)}` + ); +} + +export async function fetchLiquidityPoolOperations( + poolId: string, + network: NetworkName = 'testnet', + limit = 50 +): Promise { + const params = new URLSearchParams({ order: 'desc', limit: String(limit) }); + const data = await horizonJson<{ + _embedded?: { records?: StellarSdk.Horizon.ServerApi.OperationRecord[] }; + }>(network, `/liquidity_pools/${encodeURIComponent(poolId)}/operations?${params.toString()}`); + return data._embedded?.records ?? []; +} + +export async function fetchAccountLiquidityPoolPositions( + publicKey: string, + network: NetworkName = 'testnet' +): Promise { + const account = await fetchAccount(publicKey, network); + const balances = account.balances.filter( + (balance) => balance.asset_type === 'liquidity_pool_shares' + ) as AccountLiquidityPoolBalance[]; + + return Promise.all( + balances.map(async (balance) => { + let pool: LiquidityPoolRecord | null = null; + try { + pool = await fetchLiquidityPoolById(balance.liquidity_pool_id, network); + } catch { + pool = null; + } + + const shares = parseFloat(balance.balance); + const totalShares = parseFloat(pool?.total_shares ?? '0'); + + return { + poolId: balance.liquidity_pool_id, + balance: balance.balance, + limit: balance.limit, + sharePercent: totalShares > 0 ? (shares / totalShares) * 100 : 0, + pool, + }; + }) + ); +} + +export async function fetchAccountLiquidityPoolHistory( + publicKey: string, + network: NetworkName = 'testnet', + limit = 50, + poolId: string | null = null +): Promise { + const server = getServer(network); + const ops = await server.operations().forAccount(publicKey).order('desc').limit(limit).call(); + return (ops.records || []).filter((op) => { + const isPoolOperation = + op.type === 'liquidity_pool_deposit' || op.type === 'liquidity_pool_withdraw'; + if (!isPoolOperation) return false; + return !poolId || (op as { liquidity_pool_id?: string }).liquidity_pool_id === poolId; + }); +} diff --git a/src/lib/stellar/networks.ts b/src/lib/stellar/networks.ts new file mode 100644 index 00000000..fbeed975 --- /dev/null +++ b/src/lib/stellar/networks.ts @@ -0,0 +1,419 @@ +import * as StellarSdk from '@stellar/stellar-sdk'; +import { Cache, TTL } from '../cache.js'; +import { rateLimiter } from '../rateLimiter.js'; +import auditTrail from '../auditTrail.js'; +import { getCircuitBreaker, type CircuitState } from '../errorHandling/CircuitBreaker'; + +// ─── Cache setup ────────────────────────────────────────────────────────────── + +const stellarCache = new Cache({ + namespace: 'stellar', + persist: true, + maxSize: 500, + defaultTTL: TTL.ACCOUNT, +}); + +export { stellarCache }; + +// ─── Network config ─────────────────────────────────────────────────────────── + +export type NetworkName = 'mainnet' | 'testnet' | 'futurenet' | 'local' | 'custom'; + + +export interface NetworkConfig { + name: string; + horizonUrl: string; + sorobanUrl?: string; + passphrase: string; + faucetUrl?: string; + customHeaders?: Record; + headers?: Record; + capabilities?: import('./types').NetworkCapabilities; +} + +export const NETWORKS: Record = { + mainnet: { + name: 'Mainnet', + horizonUrl: 'https://horizon.stellar.org', + sorobanUrl: 'https://soroban-rpc.stellar.org', + passphrase: StellarSdk.Networks.PUBLIC, + capabilities: { + ledgers: true, + transactions: true, + events: true, + accountOffers: true, + fullHistory: true, + defaultReadSource: 'rpc', + }, + }, + testnet: { + name: 'Testnet', + horizonUrl: 'https://horizon-testnet.stellar.org', + sorobanUrl: 'https://soroban-testnet.stellar.org', + passphrase: StellarSdk.Networks.TESTNET, + faucetUrl: 'https://friendbot.stellar.org', + capabilities: { + ledgers: true, + transactions: true, + events: true, + accountOffers: true, + fullHistory: true, + defaultReadSource: 'rpc', + }, + }, + futurenet: { + name: 'Futurenet', + horizonUrl: 'https://horizon-futurenet.stellar.org', + sorobanUrl: 'https://soroban-futurenet.stellar.org', + passphrase: StellarSdk.Networks.FUTURENET, + faucetUrl: 'https://friendbot-futurenet.stellar.org', + capabilities: { + ledgers: true, + transactions: true, + events: true, + accountOffers: true, + fullHistory: true, + defaultReadSource: 'rpc', + }, + }, + local: { + name: 'Local', + horizonUrl: 'http://localhost:8000', + sorobanUrl: 'http://localhost:8000/soroban/rpc', + passphrase: 'Standalone Network ; February 2017', + capabilities: { + ledgers: true, + transactions: true, + events: true, + accountOffers: true, + fullHistory: true, + defaultReadSource: 'rpc', + }, + }, + custom: { + name: 'Custom', + horizonUrl: '', + sorobanUrl: '', + passphrase: '', + headers: {}, + capabilities: { + ledgers: true, + transactions: true, + events: true, + accountOffers: true, + fullHistory: true, + defaultReadSource: 'rpc', + }, + }, +}; + +const CUSTOM_NETWORK_HEADERS_KEY = 'stellar-custom-network-headers'; + +function getSessionStorage(): Storage | null { + if (typeof window === 'undefined') return null; + return window.sessionStorage || null; +} + +function normalizeHeaders(headers: Record = {}): Record { + return Object.entries(headers).reduce>((acc, [name, value]) => { + const trimmedName = String(name || '').trim(); + const trimmedValue = String(value || '').trim(); + if (trimmedName && trimmedValue) { + acc[trimmedName] = trimmedValue; + } + return acc; + }, {}); +} + +export function getCustomNetworkAuthHeaders(): Record { + const storage = getSessionStorage(); + if (!storage) return NETWORKS.custom.headers || {}; + + try { + const raw = storage.getItem(CUSTOM_NETWORK_HEADERS_KEY); + const headers = raw ? normalizeHeaders(JSON.parse(raw)) : {}; + NETWORKS.custom.headers = headers; + return headers; + } catch { + return NETWORKS.custom.headers || {}; + } +} + +function saveCustomNetworkAuthHeaders(headers: Record) { + const normalized = normalizeHeaders(headers); + NETWORKS.custom.headers = normalized; + + const storage = getSessionStorage(); + if (!storage) return; + + if (Object.keys(normalized).length) { + storage.setItem(CUSTOM_NETWORK_HEADERS_KEY, JSON.stringify(normalized)); + } else { + storage.removeItem(CUSTOM_NETWORK_HEADERS_KEY); + } +} + +function getNetworkHeaders(network: NetworkName): Record { + if (network === 'custom') return getCustomNetworkAuthHeaders(); + return NETWORKS[network].headers || {}; +} + +export function withNetworkHeaders(options: RequestInit = {}, network: NetworkName): RequestInit { + const headers = getNetworkHeaders(network); + if (!Object.keys(headers).length) return options; + + return { + ...options, + headers: { + ...(options.headers as Record | undefined), + ...headers, + }, + }; +} + +function getServerOptions(network: NetworkName) { + const headers = getNetworkHeaders(network); + return Object.keys(headers).length ? { headers } : undefined; +} + +// ─── Rate Limited Fetch Wrapper ─────────────────────────────────────────────── + +export async function rateLimitedFetch( + url: string, + options?: RequestInit, + priority: 'high' | 'medium' | 'low' = 'medium', + extraHeaders?: Record +): Promise { + const startTime = Date.now(); + + // Merge custom network headers (e.g. API keys) without mutating caller options + const mergedOptions: RequestInit = + extraHeaders && Object.keys(extraHeaders).length > 0 + ? { + ...options, + headers: { ...(options?.headers as Record | undefined), ...extraHeaders }, + } + : (options ?? {}); + + try { + // Log the API call (options without secret headers — sanitized by auditTrail) + auditTrail.logAPICall(url, mergedOptions.method || 'GET', mergedOptions, {}); + + // Check rate limits first + const check = rateLimiter.checkRequest('stellar_client', rateLimiter.extractEndpoint(url)); + + if (!check.allowed) { + // Queue the request if rate limited + const response = await rateLimiter.queueRequest( + { url, options: mergedOptions, priority }, + 'stellar_client' + ); + const responseTime = Date.now() - startTime; + + auditTrail.logAPICall(url, mergedOptions.method || 'GET', mergedOptions, { + status: response.status, + responseTime, + queued: true, + }); + + return response; + } + + // Execute request immediately if allowed + const response = await fetch(url, mergedOptions); + const responseTime = Date.now() - startTime; + + auditTrail.logAPICall(url, mergedOptions.method || 'GET', mergedOptions, { + status: response.status, + responseTime, + queued: false, + }); + + return response; + } catch (error) { + auditTrail.logError(error as Error, { url, operation: 'rateLimitedFetch' }); + throw error; + } +} + +// ─── Servers ────────────────────────────────────────────────────────────────── + +export function getNetworkDetails(network: NetworkName): NetworkConfig { + return NETWORKS[network]; +} + +export function updateCustomNetworkConfig(config: Partial) { + const { headers, ...networkConfig } = config; + Object.assign(NETWORKS.custom, networkConfig); + if (headers) saveCustomNetworkAuthHeaders(headers); +} + +/** + * Switch to a custom network profile (Issue #188). + * Updates NETWORKS.custom with profile data and creates new clients. + */ +export async function switchToCustomProfile(profileId: string): Promise { + const { getNetworkProfile } = await import('../userPreferences'); + const profile = await getNetworkProfile(profileId); + + if (!profile) { + throw new Error(`Network profile "${profileId}" not found`); + } + + // Update the custom network config + updateCustomNetworkConfig({ + name: profile.name, + horizonUrl: profile.horizonUrl, + sorobanUrl: profile.sorobanUrl, + passphrase: profile.passphrase, + }); +} + +/** + * Load profiles from storage and return them (Issue #188). + */ +export async function loadCustomNetworkProfiles() { + const { loadNetworkProfiles } = await import('../userPreferences'); + return loadNetworkProfiles(); +} + +export function getServer(network: NetworkName = 'testnet'): StellarSdk.Horizon.Server { + const config = NETWORKS[network]; + return new StellarSdk.Horizon.Server( + config.horizonUrl || NETWORKS.testnet.horizonUrl, + getServerOptions(network) + ); +} + +/** @deprecated Use getServer directly. */ +export const ee = getServer; + +export function getSorobanServer(network: NetworkName = 'testnet'): StellarSdk.SorobanRpc.Server { + const config = NETWORKS[network]; + if (network === 'custom' && !config.sorobanUrl) { + throw new Error('Custom Soroban RPC URL not configured'); + } + return new StellarSdk.rpc.Server( + config.sorobanUrl || NETWORKS.testnet.sorobanUrl!, + getServerOptions(network) + ); +} + +export type ProbeStatus = 'up' | 'degraded' | 'down'; + +export interface ServiceProbeResult { + url: string; + status: ProbeStatus; + latency: number | null; + statusCode?: number; + breakerState: CircuitState; + error?: string; +} + +export interface NetworkProbeResult { + network: NetworkName; + name: string; + horizon: ServiceProbeResult; + soroban: ServiceProbeResult; +} + +const PROBE_TIMEOUT_MS = 10_000; +const PROBE_LATENCY_DEGRADED_MS = 1_200; + +function resolveProbeStatus(response: Response, latency: number): ProbeStatus { + if (response.ok) { + return latency > PROBE_LATENCY_DEGRADED_MS ? 'degraded' : 'up'; + } + if (response.status >= 500) { + return 'down'; + } + return 'degraded'; +} + +async function probeServiceUrl( + network: NetworkName, + url: string, + serviceLabel: 'horizon' | 'soroban' +): Promise { + const serviceName = `${serviceLabel}:${network}`; + const breaker = getCircuitBreaker(serviceName, { + failureThreshold: 4, + timeout: 15_000, + }); + + if (!url) { + return { + url, + status: 'down', + latency: null, + breakerState: breaker.currentState, + error: 'URL unavailable', + }; + } + + const start = Date.now(); + let response: Response | null = null; + + try { + response = await breaker.execute(async () => { + const controller = new AbortController(); + const timeoutId = window.setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS); + try { + const headResponse = await rateLimitedFetch( + url, + { method: 'HEAD', cache: 'no-store', signal: controller.signal }, + 'low' + ); + + if (headResponse.status === 405 || headResponse.status === 501) { + return await rateLimitedFetch( + url, + { method: 'GET', cache: 'no-store', signal: controller.signal }, + 'low' + ); + } + + return headResponse; + } finally { + window.clearTimeout(timeoutId); + } + }); + + const latency = Date.now() - start; + return { + url, + status: resolveProbeStatus(response, latency), + latency, + statusCode: response.status, + breakerState: breaker.currentState, + }; + } catch (error) { + return { + url, + status: 'down', + latency: null, + breakerState: breaker.currentState, + error: String(error), + }; + } +} + +export async function probeAllNetworks(): Promise { + const probeKeys = Object.entries(NETWORKS) as [NetworkName, NetworkConfig][]; + const probes = probeKeys.map(async ([network, config]) => { + const horizon = await probeServiceUrl(network, config.horizonUrl, 'horizon'); + const soroban = await probeServiceUrl(network, config.sorobanUrl || '', 'soroban'); + + return { + network, + name: config.name, + horizon, + soroban, + }; + }); + + return Promise.all(probes); +} + + +export { StellarSdk }; diff --git a/src/lib/stellar/paths.ts b/src/lib/stellar/paths.ts new file mode 100644 index 00000000..b6c94a3f --- /dev/null +++ b/src/lib/stellar/paths.ts @@ -0,0 +1,171 @@ +import { NETWORKS, withNetworkHeaders, type NetworkName } from './networks.js'; +import { isValidPublicKey } from './addresses.js'; + +// ─── Path payments ──────────────────────────────────────────────────────────── + +export type PathPaymentMode = 'strict-send' | 'strict-receive'; + +export interface PathAsset { + type: 'native' | 'credit'; + code: string; + issuer?: string; +} + +export interface PaymentPathRecord { + source_asset_type: string; + source_asset_code?: string; + source_asset_issuer?: string; + source_amount: string; + destination_asset_type: string; + destination_asset_code?: string; + destination_asset_issuer?: string; + destination_amount: string; + path: Array<{ + asset_type: string; + asset_code?: string; + asset_issuer?: string; + }>; + /** Slippage % vs best path — annotated client-side, not from Horizon */ + slippagePct?: string; +} + +export interface FetchPaymentPathsParams { + sourceAsset: PathAsset; + destAsset: PathAsset; + amount: string; + mode?: PathPaymentMode; + network?: NetworkName; +} + +export type PathPaymentErrorCode = + | 'INVALID_INPUT' + | 'UNSUPPORTED_NETWORK' + | 'HORIZON_ERROR' + | 'REQUEST_FAILED'; + +/** A user-safe path quote failure with a stable code for UI handling. */ +export class PathPaymentError extends Error { + constructor( + public readonly code: PathPaymentErrorCode, + message: string, + public readonly status?: number + ) { + super(message); + this.name = 'PathPaymentError'; + } +} + + + +export async function fetchPaymentPaths( + params: FetchPaymentPathsParams +): Promise { + const { sourceAsset, destAsset, amount, mode = 'strict-send', network = 'testnet' } = params; + + if (mode !== 'strict-send' && mode !== 'strict-receive') { + throw new PathPaymentError('INVALID_INPUT', 'Choose either strict-send or strict-receive mode.'); + } + + if (!/^\d+(\.\d{1,7})?$/.test(amount) || Number(amount) <= 0) { + throw new PathPaymentError( + 'INVALID_INPUT', + 'Amount must be a positive decimal with no more than 7 decimal places.' + ); + } + + function validateAsset(asset: PathAsset, label: string): void { + if (!asset || (asset.type !== 'native' && asset.type !== 'credit')) { + throw new PathPaymentError('INVALID_INPUT', `${label} asset type is invalid.`); + } + if (asset.type === 'credit') { + if (!/^[a-zA-Z0-9]{1,12}$/.test(asset.code)) { + throw new PathPaymentError('INVALID_INPUT', `${label} asset code must contain 1 to 12 letters or numbers.`); + } + if (!asset.issuer || !isValidPublicKey(asset.issuer)) { + throw new PathPaymentError('INVALID_INPUT', `${label} asset issuer must be a valid Stellar G address.`); + } + } + } + + validateAsset(sourceAsset, 'Source'); + validateAsset(destAsset, 'Destination'); + + const networkConfig = NETWORKS[network]; + if (!networkConfig?.horizonUrl) { + throw new PathPaymentError( + 'UNSUPPORTED_NETWORK', + `Path quotes are not available for the ${network || 'selected'} network.` + ); + } + const horizonUrl = networkConfig.horizonUrl.replace(/\/$/, ''); + + function assetParams(asset: PathAsset, prefix: string): string { + if (asset.type === 'native') { + return `${prefix}_asset_type=native`; + } + const alphaNum = asset.code.length <= 4 ? '4' : '12'; + return `${prefix}_asset_type=credit_alphanum${alphaNum}&${prefix}_asset_code=${asset.code}&${prefix}_asset_issuer=${asset.issuer}`; + } + + function assetString(asset: PathAsset): string { + if (asset.type === 'native') return 'native'; + return `${asset.code}:${asset.issuer}`; + } + + let url: string; + if (mode === 'strict-send') { + url = `${horizonUrl}/paths/strict-send?${assetParams(sourceAsset, 'source')}&source_amount=${amount}&destination_assets=${encodeURIComponent(assetString(destAsset))}`; + } else { + url = `${horizonUrl}/paths/strict-receive?${assetParams(destAsset, 'destination')}&destination_amount=${amount}&source_assets=${encodeURIComponent(assetString(sourceAsset))}`; + } + + let res: Response; + try { + res = await fetch(url, withNetworkHeaders({}, network)); + } catch { + throw new PathPaymentError( + 'REQUEST_FAILED', + 'Could not reach Horizon. Check your connection and try again.' + ); + } + + if (!res.ok) { + let detail = ''; + try { + const problem = (await res.json()) as { detail?: string }; + detail = problem.detail ? ` ${problem.detail}` : ''; + } catch { + // Horizon may return an empty or non-JSON proxy response. + } + throw new PathPaymentError( + 'HORIZON_ERROR', + `Horizon could not produce a path quote (${res.status}).${detail}`, + res.status + ); + } + + let records: PaymentPathRecord[]; + try { + const data = (await res.json()) as { _embedded?: { records?: PaymentPathRecord[] } }; + records = Array.isArray(data._embedded?.records) ? data._embedded.records : []; + } catch { + throw new PathPaymentError('HORIZON_ERROR', 'Horizon returned an invalid path quote response.'); + } + + const amountFor = (record: PaymentPathRecord) => Number( + mode === 'strict-send' ? record.destination_amount : record.source_amount + ); + const validRecords = records.filter((record) => Number.isFinite(amountFor(record)) && amountFor(record) > 0); + validRecords.sort((a, b) => mode === 'strict-send' + ? amountFor(b) - amountFor(a) + : amountFor(a) - amountFor(b)); + + const bestAmount = validRecords[0] ? amountFor(validRecords[0]) : 0; + return validRecords.map((record) => { + const quotedAmount = amountFor(record); + const slippage = mode === 'strict-send' + ? ((bestAmount - quotedAmount) / bestAmount) * 100 + : ((quotedAmount - bestAmount) / bestAmount) * 100; + return { ...record, slippagePct: slippage.toFixed(2) }; + }); +} diff --git a/src/lib/stellar/pricing.ts b/src/lib/stellar/pricing.ts new file mode 100644 index 00000000..6c364e39 --- /dev/null +++ b/src/lib/stellar/pricing.ts @@ -0,0 +1,119 @@ +import { TTL } from '../cache.js'; +import { NETWORKS, stellarCache, withNetworkHeaders, type NetworkName } from './networks.js'; + +const COINGECKO_XLM_PRICE_URL = + 'https://api.coingecko.com/api/v3/simple/price?ids=stellar&vs_currencies=usd'; + +export interface XLMPrice { + usd: number; + source: 'coingecko'; +} + +export interface AssetPriceEstimate { + xlm: number; + source: 'sdex'; + method: 'midpoint' | 'best_bid' | 'best_ask'; + bestBid: number | null; + bestAsk: number | null; +} + +export interface AssetBalanceLike { + asset_type: string; + asset_code?: string; + asset_issuer?: string; +} + +function parseTopOfBookPrice(levels: Array<{ price?: string }> = []): number | null { + const price = parseFloat(levels[0]?.price ?? ''); + if (!Number.isFinite(price) || price <= 0) return null; + return price; +} + +export async function fetchXLMPrice(): Promise { + const cacheKey = 'xlm-price'; + const cached = stellarCache.get(cacheKey); + if (cached) return cached; + + const response = await fetch(COINGECKO_XLM_PRICE_URL); + + if (!response.ok) { + throw new Error(`XLM price request failed: ${response.status}`); + } + + const data = await response.json(); + const usd = data?.stellar?.usd; + + if (!Number.isFinite(usd)) { + throw new Error('XLM price data unavailable'); + } + + const result: XLMPrice = { + usd, + source: 'coingecko', + }; + stellarCache.set(cacheKey, result, TTL.PRICE, ['price', 'xlm']); + return result; +} + +export async function fetchAssetPrice( + asset: AssetBalanceLike, + network: NetworkName = 'testnet' +): Promise { + if (!asset || asset.asset_type === 'native') return null; + + if (!asset.asset_type.startsWith('credit_alphanum') || !asset.asset_code || !asset.asset_issuer) { + return null; + } + + const cacheKey = `asset-price:${asset.asset_code}:${asset.asset_issuer}:${network}`; + const cached = stellarCache.get(cacheKey); + if (cached) return cached; + + const params = new URLSearchParams({ + selling_asset_type: asset.asset_type, + selling_asset_code: asset.asset_code, + selling_asset_issuer: asset.asset_issuer, + buying_asset_type: 'native', + }); + + const response = await fetch( + `${NETWORKS[network].horizonUrl}/order_book?${params.toString()}`, + withNetworkHeaders({}, network) + ); + + if (!response.ok) { + throw new Error(`Order book request failed: ${response.status}`); + } + + const orderBook = await response.json(); + const bestBid = parseTopOfBookPrice(orderBook.bids); + const bestAsk = parseTopOfBookPrice(orderBook.asks); + + let result: AssetPriceEstimate | null = null; + + if (bestBid !== null && bestAsk !== null) { + result = { + xlm: (bestBid + bestAsk) / 2, + source: 'sdex', + method: 'midpoint', + bestBid, + bestAsk, + }; + } else { + const fallback = bestBid ?? bestAsk; + if (fallback !== null) { + result = { + xlm: fallback, + source: 'sdex', + method: bestBid !== null ? 'best_bid' : 'best_ask', + bestBid, + bestAsk, + }; + } + } + + if (result) { + stellarCache.set(cacheKey, result, TTL.ASSET, ['price', asset.asset_code]); + } + return result; +} diff --git a/src/lib/stellar/readSources.ts b/src/lib/stellar/readSources.ts new file mode 100644 index 00000000..fcf0bbee --- /dev/null +++ b/src/lib/stellar/readSources.ts @@ -0,0 +1,61 @@ +import { getNetworkDetails, type NetworkName } from './networks.js'; +import { StellarReadSource } from './types'; +import { HorizonReadSource } from './horizonReadSource'; +import { RpcReadSource } from './rpcReadSource'; +import { RpcFirstReadSource } from './rpcFirstReadSource'; +import { isOnline } from '../offlineReadOnly'; + +export * from './types'; +export * from './horizonReadSource'; +export * from './rpcReadSource'; +export * from './rpcFirstReadSource'; + +export type ReadSourceMode = 'rpc-first' | 'rpc-only' | 'horizon-only'; + +/** + * Factory function to instantiate a Stellar read source for a given network profile. + */ +export function getStellarReadSource( + network: NetworkName = 'testnet', + mode: ReadSourceMode = 'rpc-first' +): StellarReadSource { + switch (mode) { + case 'rpc-only': + return new RpcReadSource(network); + case 'horizon-only': + return new HorizonReadSource(network); + case 'rpc-first': + default: + return new RpcFirstReadSource(network); + } +} + +/** + * Helper to evaluate data source capabilities and connectivity for a network profile. + */ +export function evaluateReadSourceCapabilities(network: NetworkName = 'testnet') { + const config = getNetworkDetails(network); + const online = isOnline(); + const hasHorizon = Boolean(config?.horizonUrl); + const hasRpc = Boolean(config?.sorobanUrl); + + const capabilities = { + ledgers: online && (hasHorizon || hasRpc), + transactions: online && (hasHorizon || hasRpc), + events: online && (hasHorizon || hasRpc), + accountOffers: online && hasHorizon, + fullHistory: online && hasHorizon, + defaultReadSource: hasRpc ? ('rpc' as const) : ('horizon' as const), + retentionLimitLedgers: hasRpc ? 100_000 : undefined, + isRpcOnly: hasRpc && !hasHorizon, + isHorizonOnly: hasHorizon && !hasRpc, + }; + + return { + network, + online, + hasHorizon, + hasRpc, + capabilities, + }; +} diff --git a/src/lib/stellar/reserves.ts b/src/lib/stellar/reserves.ts new file mode 100644 index 00000000..f71e7013 --- /dev/null +++ b/src/lib/stellar/reserves.ts @@ -0,0 +1,68 @@ +import * as StellarSdk from '@stellar/stellar-sdk'; +import type { NetworkStats } from './horizon.js'; + +export interface AccountReserves { + baseReserve: number; + signerReserve: number; + assetReserve: number; + offerReserve: number; + subentryReserve: number; + totalReserves: number; + availableBalance: number; + totalBalance: number; +} + +/** + * Calculate account reserves based on Stellar network base reserve + * @param accountData - Account response from Horizon + * @param networkStats - Network stats containing ledger with base_reserve + * @param offerCount - Number of open offers (optional, defaults to 0) + * @returns AccountReserves object with breakdown of all reserves + */ +export function calculateAccountReserves( + accountData: StellarSdk.Horizon.AccountResponse, + networkStats: NetworkStats | null, + offerCount: number = 0 +): AccountReserves { + // Get base reserve from ledger (in stroops, convert to XLM) + // Default to 1 XLM if not available (current Stellar default) + const baseReserveStroops = Number(networkStats?.latestLedger?.base_reserve) || 10000000; + const baseReserve = baseReserveStroops / 10000000; // Convert stroops to XLM + + // Count non-native assets (trustlines) + const assetCount = accountData.balances?.filter((b) => b.asset_type !== 'native').length || 0; + + // Count signers (excluding the master key if it's a signer) + const signerCount = + accountData.signers?.filter((s) => s.key !== accountData.account_id).length || 0; + + // Subentry count from account data + const subentryCount = accountData.subentry_count || 0; + + // Calculate reserves (each additional entry costs base_reserve / 2) + const signerReserve = signerCount * (baseReserve / 2); + const assetReserve = assetCount * (baseReserve / 2); + const offerReserve = offerCount * (baseReserve / 2); + const subentryReserve = subentryCount * (baseReserve / 2); + + // Total reserves + const totalReserves = baseReserve + signerReserve + assetReserve + offerReserve + subentryReserve; + + // Get XLM balance + const xlmBalance = accountData.balances?.find((b) => b.asset_type === 'native')?.balance || '0'; + const totalBalance = parseFloat(xlmBalance); + + // Available balance (total - reserves) + const availableBalance = Math.max(0, totalBalance - totalReserves); + + return { + baseReserve, + signerReserve, + assetReserve, + offerReserve, + subentryReserve, + totalReserves, + availableBalance, + totalBalance, + }; +} diff --git a/src/lib/stellar/rpcFirstReadSource.ts b/src/lib/stellar/rpcFirstReadSource.ts index 7895cb37..515ed109 100644 --- a/src/lib/stellar/rpcFirstReadSource.ts +++ b/src/lib/stellar/rpcFirstReadSource.ts @@ -1,4 +1,4 @@ -import { NetworkName, getNetworkDetails } from '../stellar'; +import { NetworkName, getNetworkDetails } from './networks.js'; import { HorizonReadSource } from './horizonReadSource'; import { RpcReadSource } from './rpcReadSource'; import { diff --git a/src/lib/stellar/rpcReadSource.ts b/src/lib/stellar/rpcReadSource.ts index 7186f09e..bef7fed7 100644 --- a/src/lib/stellar/rpcReadSource.ts +++ b/src/lib/stellar/rpcReadSource.ts @@ -1,5 +1,5 @@ import * as StellarSdk from '@stellar/stellar-sdk'; -import { getSorobanServer, NetworkName, NETWORKS } from '../stellar'; +import { getSorobanServer, NetworkName, NETWORKS } from './networks.js'; import { StellarReadSource, GetLedgersParams, diff --git a/src/lib/stellar/simulation.ts b/src/lib/stellar/simulation.ts new file mode 100644 index 00000000..609c3a84 --- /dev/null +++ b/src/lib/stellar/simulation.ts @@ -0,0 +1,470 @@ +import * as StellarSdk from '@stellar/stellar-sdk'; +import { Cache, TTL } from '../cache.js'; +import { checkDestinationMemoRequirement, isValidPublicKey } from './addresses.js'; +import { getSorobanServer } from './networks.js'; +import { buildTransaction, type BuildTransactionParams, type PaymentOperation } from './transactionBuilder.js'; +import { serializeLedgerKey, serializeDiagnosticEvent, type SerializedLedgerKey, type SerializedContractEvent } from './soroban.js'; +import { shortAddress } from './formatters.js'; + +const simulationCache = new Cache({ + namespace: 'simulation', + maxSize: 200, + defaultTTL: TTL.SHORT, +}); + +function buildSimulationCacheKey(params: BuildTransactionParams) { + return simulationCache.generateKey('simulate', { + sourceAccount: params.sourceAccount, + operations: params.operations, + memo: params.memo, + baseFee: params.baseFee, + timeBounds: params.timeBounds, + preconditions: params.preconditions, + network: params.network, + }); +} + +function validateSimulationParams(params: BuildTransactionParams) { + const errors: string[] = []; + const warnings: string[] = []; + + if (!params.sourceAccount || !isValidPublicKey(params.sourceAccount)) { + errors.push('Source account is required and must be a valid Stellar public key.'); + } + + if (!Array.isArray(params.operations) || params.operations.length === 0) { + errors.push('Transaction must include at least one operation.'); + } + + if (!Number.isFinite(params.baseFee) || params.baseFee <= 0) { + errors.push('Base fee must be a positive number.'); + } + + if (params.timeBounds?.minTime && params.timeBounds?.maxTime) { + const minTime = Number(params.timeBounds.minTime); + const maxTime = Number(params.timeBounds.maxTime); + if (Number.isNaN(minTime) || Number.isNaN(maxTime)) { + errors.push('Time bounds must be valid Unix timestamps.'); + } else if (minTime > maxTime) { + errors.push('Min time cannot be greater than max time.'); + } + } + + if (params.preconditions) { + if (params.preconditions.ledgerBounds) { + const minLedger = Number(params.preconditions.ledgerBounds.minLedger); + const maxLedger = Number(params.preconditions.ledgerBounds.maxLedger); + if (!Number.isNaN(minLedger) && minLedger < 0) { + errors.push('Ledger bounds minLedger cannot be negative.'); + } + if (!Number.isNaN(maxLedger) && maxLedger < 0) { + errors.push('Ledger bounds maxLedger cannot be negative.'); + } + if (!Number.isNaN(minLedger) && !Number.isNaN(maxLedger) && maxLedger > 0 && minLedger > maxLedger) { + errors.push('Ledger bounds minLedger cannot be greater than maxLedger.'); + } + } + + if (params.preconditions.minSequence !== undefined && params.preconditions.minSequence !== '') { + const minSeq = Number(params.preconditions.minSequence); + if (Number.isNaN(minSeq) || minSeq < 0) { + errors.push('Min sequence cannot be negative.'); + } + } + + if (params.preconditions.minSequenceAge !== undefined && params.preconditions.minSequenceAge !== '') { + const age = Number(params.preconditions.minSequenceAge); + if (Number.isNaN(age) || age < 0) { + errors.push('Min sequence age cannot be negative.'); + } + } + + if (params.preconditions.minSequenceLedgerGap !== undefined && params.preconditions.minSequenceLedgerGap !== '') { + const gap = Number(params.preconditions.minSequenceLedgerGap); + if (Number.isNaN(gap) || gap < 0) { + errors.push('Min sequence ledger gap cannot be negative.'); + } + } + + if (params.preconditions.extraSigners) { + const invalid = params.preconditions.extraSigners.filter((s) => !isValidPublicKey(s)); + if (invalid.length > 0) { + errors.push(`Invalid extra signer public key(s): ${invalid.join(', ')}`); + } + } + } + + if (typeof params.memo === 'string' && params.memo.length > 28) { + warnings.push('Memo text may exceed the 28-character limit accepted by the Stellar network.'); + } + + params.operations?.forEach((op, index) => { + if (op.type === 'payment') { + if (!isValidPublicKey(op.destination)) { + errors.push(`Operation ${index + 1}: Invalid destination address.`); + } + if (!op.amount || parseFloat(String(op.amount)) <= 0) { + errors.push(`Operation ${index + 1}: Amount must be greater than zero.`); + } + } else if (op.type === 'createAccount') { + if (!isValidPublicKey(op.destination)) { + errors.push(`Operation ${index + 1}: Invalid destination address.`); + } + if (!op.startingBalance || parseFloat(String(op.startingBalance)) < 1) { + errors.push(`Operation ${index + 1}: Starting balance must be at least 1 XLM.`); + } + } else if (op.type === 'invokeHostFunction') { + if (!op.func) { + errors.push(`Operation ${index + 1}: Missing host function payload.`); + } + } + }); + + return { errors, warnings }; +} + +export function getSimulationFeeOptions( + baseFee: number, + operationCount: number, + congestion = 0.55 +) { + const optimized = optimizeTransactionFee(baseFee, operationCount, congestion); + + return [ + { + label: 'Slow / Cost Saver', + fee: Math.max(100, Math.floor(optimized * 0.85)), + expectedInclusion: 'slow', + }, + { + label: 'Standard', + fee: optimized, + expectedInclusion: 'standard', + }, + { + label: 'Priority', + fee: Math.ceil(optimized * 1.2), + expectedInclusion: 'priority', + }, + ]; +} + +// ─── Simulate transaction ───────────────────────────────────────────────────── + +export interface SimulateResult { + fee: number; + operationCount: number; + success: boolean; + errors: string[]; + warnings?: string[]; + feeOptions?: SimulationFeeOption[]; + xdr?: string; + resourceUsage?: { + cpuInstructions: number; + memoryBytes: number; + ledgerReadWrite: number; + ledgerReadOnly: number; + }; + sorobanMetrics?: { + footprint: { + readOnly: SerializedLedgerKey[]; + readWrite: SerializedLedgerKey[]; + }; + resourceFee: string; + refundableFee?: string; + events?: SerializedContractEvent[]; + }; +} + +export async function simulateTransaction(params: BuildTransactionParams): Promise { + const cacheKey = buildSimulationCacheKey(params); + const cached = simulationCache.get(cacheKey); + if (cached) { + return cached; + } + + const feeBumpOnly = params.operations.length === 1 && params.operations[0].type === 'feeBump'; + + // validateSimulationParams requires a sourceAccount — skip that check for fee-bump + // since the fee source lives inside the operation params, not at the top level. + const validation = feeBumpOnly + ? { errors: [] as string[], warnings: [] as string[] } + : validateSimulationParams(params); + + const errors = [...validation.errors]; + const warnings = [...validation.warnings]; + let transaction: StellarSdk.Transaction | StellarSdk.FeeBumpTransaction | null = null; + let sorobanMetrics = undefined; + + if (!params.memo) { + const paymentDestinations = (params.operations || []) + .filter((op): op is PaymentOperation => op.type === 'payment' && Boolean((op as PaymentOperation).destination)) + .map((op) => op.destination); + + for (const destination of paymentDestinations) { + const memoCheck = await checkDestinationMemoRequirement(destination, params.network); + if (memoCheck.checked && memoCheck.required) { + warnings.push( + `Destination ${shortAddress(destination)} requires a memo (SEP-29). Add one before submitting or the network will reject this transaction.` + ); + } + // Unsupported environments (federated/contract addresses, network errors) + // are surfaced via memoCheck.error but are non-fatal — we skip the warning. + } + } + + if (errors.length === 0) { + try { + transaction = await buildTransaction(params); + } catch (error) { + errors.push(`Transaction assembly failed: ${(error as Error).message}`); + } + } + + const result: SimulateResult = { + fee: 0, + operationCount: params.operations.length, + success: false, + errors, + warnings: warnings.length ? warnings : undefined, + }; + + if (transaction) { + const fee = parseInt(transaction.fee.toString(), 10); + + // For fee-bump transactions, operation count = inner ops + 1 (the bump itself). + // For plain transactions, it's the direct operation count. + let operationCount: number; + if (transaction instanceof StellarSdk.FeeBumpTransaction) { + operationCount = transaction.innerTransaction.operations.length + 1; + } else { + operationCount = (transaction as StellarSdk.Transaction).operations.length; + } + + const feeOptions = getSimulationFeeOptions(params.baseFee, operationCount); + + const hasSorobanOps = params.operations.some((op) => op.type === 'invokeHostFunction'); + if (hasSorobanOps && transaction instanceof StellarSdk.Transaction) { + try { + const sorobanServer = getSorobanServer(params.network); + const simulation = await sorobanServer.simulateTransaction(transaction); + + if ('error' in simulation) { + errors.push(`Soroban simulation error: ${simulation.error}`); + } else { + const successfulSimulation = simulation as any; + if (successfulSimulation.transactionData) { + const readOnly = successfulSimulation.transactionData.getReadOnly(); + const readWrite = successfulSimulation.transactionData.getReadWrite(); + const cost = successfulSimulation.cost as { cpuInstructions?: number; memoryBytes?: number } | undefined; + const cpuInstructions = cost?.cpuInstructions ?? Math.max(100_000, readWrite.length * 200_000 + readOnly.length * 50_000 + operationCount * 25_000); + const memoryBytes = cost?.memoryBytes ?? Math.max(1024, (readWrite.length + readOnly.length) * 2048 + operationCount * 512); + const resourceFee = successfulSimulation.minResourceFee; + const refundableFee = resourceFee ? Math.floor(parseInt(resourceFee, 10) * 0.3) : undefined; + + sorobanMetrics = { + footprint: { + readOnly: readOnly.map(serializeLedgerKey), + readWrite: readWrite.map(serializeLedgerKey), + }, + resourceFee, + refundableFee: refundableFee?.toString(), + events: (successfulSimulation.events || []).map(serializeDiagnosticEvent), + }; + + result.resourceUsage = { + cpuInstructions, + memoryBytes, + ledgerReadWrite: readWrite.length, + ledgerReadOnly: readOnly.length, + }; + } + } + } catch (e) { + console.warn('Soroban simulation failed:', e); + } + } + + result.fee = fee; + result.operationCount = operationCount; + result.success = errors.length === 0; + result.xdr = transaction.toXDR(); + result.feeOptions = feeOptions; + result.sorobanMetrics = sorobanMetrics; + } + + simulationCache.set(cacheKey, result, TTL.SHORT, ['simulation', params.network]); + return result; +} + +export interface SimulationWhatIfScenario { + label: string; + baseFee?: number; + operationMultiplier?: number; + networkCongestion?: number; +} + +export interface SimulationFeeOption { + label: string; + fee: number; + expectedInclusion: 'slow' | 'standard' | 'priority'; +} + +export interface ExecutionTraceStep { + step: string; + status: 'ok' | 'warning' | 'error'; + detail: string; +} + +export interface AdvancedSimulationParams extends BuildTransactionParams { + scenarios?: SimulationWhatIfScenario[]; + currentLedgerLoad?: number; +} + +export interface AdvancedSimulationReport { + base: SimulateResult; + optimizedFee: number; + feeOptions: SimulationFeeOption[]; + successProbability: number; + executionTrace: ExecutionTraceStep[]; + scenarios: Array<{ + label: string; + estimatedFee: number; + successProbability: number; + }>; +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +export function optimizeTransactionFee( + baseFee: number, + operationCount: number, + currentLedgerLoad = 0.5 +): number { + const congestionMultiplier = 1 + clamp(currentLedgerLoad, 0, 1.5) * 0.85; + const opWeight = 1 + Math.max(0, operationCount - 1) * 0.08; + return Math.ceil(baseFee * congestionMultiplier * opWeight); +} + +export function scoreTransactionSuccess( + simulation: SimulateResult, + currentLedgerLoad = 0.5 +): number { + if (!simulation.success) return 0; + + const errorPenalty = simulation.errors.length * 0.2; + const congestionPenalty = clamp(currentLedgerLoad, 0, 1.5) * 0.28; + const opPenalty = Math.max(0, simulation.operationCount - 2) * 0.04; + const raw = 0.95 - errorPenalty - congestionPenalty - opPenalty; + return clamp(raw, 0.05, 0.99); +} + +export function buildExecutionTrace( + params: BuildTransactionParams, + simulation: SimulateResult +): ExecutionTraceStep[] { + const steps: ExecutionTraceStep[] = [ + { + step: 'Validate source account', + status: isValidPublicKey(params.sourceAccount) ? 'ok' : 'error', + detail: isValidPublicKey(params.sourceAccount) + ? 'Source account format is valid.' + : 'Source account is not a valid ed25519 public key.', + }, + { + step: 'Assemble operations', + status: params.operations.length > 0 ? 'ok' : 'error', + detail: `${params.operations.length} operation(s) attached to transaction.`, + }, + { + step: 'Estimate fee and bounds', + status: params.baseFee >= 100 ? 'ok' : 'warning', + detail: `Base fee ${params.baseFee} stroops with ${params.timeBounds.maxTime ? 'custom' : 'default'} time bounds.`, + }, + { + step: 'Simulate preflight', + status: simulation.success ? 'ok' : 'error', + detail: simulation.success + ? 'Simulation succeeded with no blocking errors.' + : simulation.errors.join('; '), + }, + { + step: 'Soroban Resource Preview', + status: simulation.sorobanMetrics ? 'ok' : 'warning', + detail: simulation.sorobanMetrics + ? `Footprint: ${simulation.sorobanMetrics.footprint.readOnly.length} RO, ${simulation.sorobanMetrics.footprint.readWrite.length} RW keys. Min fee: ${simulation.sorobanMetrics.resourceFee} stroops.` + : 'Soroban metrics not available for this transaction.', + }, + ]; + + return steps; +} + +export async function runAdvancedTransactionSimulation( + params: AdvancedSimulationParams +): Promise { + const base = await simulateTransaction(params); + const operationCount = Math.max(1, params.operations.length); + const currentLedgerLoad = params.currentLedgerLoad ?? 0.55; + const optimizedFee = optimizeTransactionFee(params.baseFee, operationCount, currentLedgerLoad); + const successProbability = scoreTransactionSuccess(base, currentLedgerLoad); + const executionTrace = buildExecutionTrace(params, base); + + const feeOptions: SimulationFeeOption[] = [ + { + label: 'Slow / Cost Saver', + fee: Math.max(100, Math.floor(optimizedFee * 0.85)), + expectedInclusion: 'slow', + }, + { + label: 'Standard', + fee: optimizedFee, + expectedInclusion: 'standard', + }, + { + label: 'Priority', + fee: Math.ceil(optimizedFee * 1.2), + expectedInclusion: 'priority', + }, + ]; + + const scenarios = (params.scenarios || []).map((scenario) => { + const scenarioOps = Math.max( + 1, + Math.round(operationCount * (scenario.operationMultiplier ?? 1)) + ); + const scenarioFee = optimizeTransactionFee( + scenario.baseFee ?? params.baseFee, + scenarioOps, + scenario.networkCongestion ?? currentLedgerLoad + ); + + const scenarioProbability = clamp( + successProbability - (scenario.networkCongestion ?? currentLedgerLoad) * 0.15 + 0.05, + 0.03, + 0.99 + ); + + return { + label: scenario.label, + estimatedFee: scenarioFee, + successProbability: scenarioProbability, + }; + }); + + return { + base, + optimizedFee, + feeOptions, + successProbability, + executionTrace, + scenarios, + }; +} + +export async function exportTransactionXDR(params: BuildTransactionParams): Promise { + const transaction = await buildTransaction(params); + return transaction.toXDR(); +} diff --git a/src/lib/stellar/soroban.ts b/src/lib/stellar/soroban.ts new file mode 100644 index 00000000..4a76a8fb --- /dev/null +++ b/src/lib/stellar/soroban.ts @@ -0,0 +1,297 @@ +import * as StellarSdk from '@stellar/stellar-sdk'; +import { NETWORKS, getServer, getSorobanServer, type NetworkName } from './networks.js'; +import { isValidContractId, isValidPublicKey } from './addresses.js'; + +// ─── Contract ───────────────────────────────────────────────────────────────── + +export async function fetchContractInfo( + contractId: string, + network: NetworkName = 'testnet' +): Promise { + const server = getSorobanServer(network); + try { + const instance = await server.getContractData( + contractId, + StellarSdk.xdr.ScVal.scvLedgerKeyContractInstance(), + StellarSdk.rpc.Durability.Persistent + ); + return instance; + } catch (e) { + throw new Error(`Contract not found: ${(e as Error).message}`); + } +} + +export async function fetchContractData( + contractId: string, + key: StellarSdk.xdr.ScVal | string, + network: NetworkName = 'testnet', + durability: StellarSdk.rpc.Durability = StellarSdk.SorobanRpc.Durability.Persistent +): Promise { + const server = getSorobanServer(network); + + let scValKey; + if (typeof key === 'string') { + try { + // Try to parse from JSON first + const parsed = JSON.parse(key); + scValKey = StellarSdk.nativeToScVal(parsed); + } catch { + // If JSON fails, treat as string + scValKey = StellarSdk.nativeToScVal(key, { type: 'string' }); + } + } else { + scValKey = key; + } + + try { + const result = await server.getContractData(contractId, scValKey, durability); + return { + key: StellarSdk.scValToNative(result.key), + value: StellarSdk.scValToNative(result.val), + xdr: result.xdr, + }; + } catch (e) { + throw new Error(`Failed to fetch contract data: ${(e as Error).message}`); + } +} + +export interface ContractInvocationArg { + type: 'string' | 'int' | 'address' | 'bool'; + value: string; +} + +export interface SerializedLedgerKey { + type: string; + xdr: string; +} + +export interface SerializedContractEvent { + inSuccessfulContractCall: boolean; + type: string; + contractId: string | null; + topics: unknown[]; + value: unknown; +} + +export interface ContractSimulationResult { + xdr: string; + latestLedger: number; + cost?: StellarSdk.rpc.Api.Cost; + result: unknown; + events: SerializedContractEvent[]; + footprint: { + readOnly: SerializedLedgerKey[]; + readWrite: SerializedLedgerKey[]; + minResourceFee: string; + } | null; +} + +export interface ContractSubmitResult { + hash: string; + status: StellarSdk.rpc.Api.SendTransactionStatus; + errorResult: string | null; + diagnosticEvents: string[]; +} + +function getLedgerKeyType(key: StellarSdk.xdr.LedgerKey): string { + const kind = key.switch(); + return kind?.name || kind?.toString?.() || 'unknown'; +} + +export function serializeLedgerKey(key: StellarSdk.xdr.LedgerKey): SerializedLedgerKey { + return { + type: getLedgerKeyType(key), + xdr: key.toXDR('base64'), + }; +} + +function serializeScVal(value: StellarSdk.xdr.ScVal): unknown { + try { + return StellarSdk.scValToNative(value); + } catch { + return value.toXDR('base64'); + } +} + +export function serializeDiagnosticEvent(event: StellarSdk.xdr.DiagnosticEvent): SerializedContractEvent { + const contractEvent = event.event(); + const body = contractEvent.body().v0(); + const contractId = contractEvent.contractId(); + + return { + inSuccessfulContractCall: event.inSuccessfulContractCall(), + type: contractEvent.type().name || contractEvent.type().toString(), + contractId: contractId + ? StellarSdk.Address.fromScAddress( + contractId as unknown as StellarSdk.xdr.ScAddress + ).toString() + : null, + topics: body.topics().map(serializeScVal), + value: serializeScVal(body.data()), + }; +} + +function parseContractArgument(arg: ContractInvocationArg, index: number): StellarSdk.xdr.ScVal { + const trimmedValue = arg.value?.trim?.() ?? ''; + + if (!trimmedValue) { + throw new Error(`Argument ${index + 1} is empty`); + } + + switch (arg.type) { + case 'string': + return StellarSdk.nativeToScVal(trimmedValue, { type: 'string' }); + case 'int': { + let parsed: bigint; + try { + parsed = BigInt(trimmedValue); + } catch { + throw new Error(`Argument ${index + 1} must be a valid integer`); + } + return StellarSdk.nativeToScVal(parsed, { type: 'i128' }); + } + case 'address': + try { + return StellarSdk.Address.fromString(trimmedValue).toScVal(); + } catch { + throw new Error(`Argument ${index + 1} must be a valid Stellar address`); + } + case 'bool': + if (trimmedValue !== 'true' && trimmedValue !== 'false') { + throw new Error(`Argument ${index + 1} must be true or false`); + } + return StellarSdk.nativeToScVal(trimmedValue === 'true', { type: 'bool' }); + default: + throw new Error(`Unsupported argument type: ${arg.type}`); + } +} + +interface BuildContractInvocationParams { + contractId: string; + functionName: string; + args?: ContractInvocationArg[]; + sourceAccount: string; + network?: NetworkName; +} + +async function buildContractInvocationTransaction( + params: BuildContractInvocationParams +): Promise { + const { contractId, functionName, args = [], sourceAccount, network = 'testnet' } = params; + + if (!isValidContractId(contractId)) { + throw new Error('Invalid contract address'); + } + + if (!functionName.trim()) { + throw new Error('Function name is required'); + } + + if (!isValidPublicKey(sourceAccount)) { + throw new Error('A valid source account is required'); + } + + const horizon = getServer(network); + const account = await horizon.loadAccount(sourceAccount); + const contract = new StellarSdk.Contract(contractId.trim()); + const parsedArgs = args.map(parseContractArgument); + + return new StellarSdk.TransactionBuilder(account, { + fee: StellarSdk.BASE_FEE.toString(), + networkPassphrase: NETWORKS[network].passphrase, + }) + .setTimeout(30) + .addOperation(contract.call(functionName.trim(), ...parsedArgs)) + .build(); +} + +export async function simulateContractCall( + params: BuildContractInvocationParams +): Promise { + const { network = 'testnet' } = params; + const server = getSorobanServer(network); + const transaction = await buildContractInvocationTransaction(params); + const simulation = await server.simulateTransaction(transaction); + + if ('error' in simulation && simulation.error) { + throw new Error(simulation.error); + } + + const successfulSimulation = simulation as Exclude< + StellarSdk.rpc.Api.SimulateTransactionResponse, + StellarSdk.rpc.Api.SimulateTransactionErrorResponse + >; + + const footprint = successfulSimulation.transactionData + ? { + readOnly: successfulSimulation.transactionData.getReadOnly().map(serializeLedgerKey), + readWrite: successfulSimulation.transactionData.getReadWrite().map(serializeLedgerKey), + minResourceFee: successfulSimulation.minResourceFee, + } + : null; + + return { + xdr: transaction.toXDR(), + latestLedger: successfulSimulation.latestLedger, + cost: successfulSimulation.cost, + result: successfulSimulation.result ? serializeScVal(successfulSimulation.result.retval) : null, + events: (successfulSimulation.events || []).map(serializeDiagnosticEvent), + footprint, + }; +} + +interface InvokeContractParams { + contractId: string; + functionName: string; + args?: ContractInvocationArg[]; + secretKey: string; + network?: NetworkName; +} + +export async function invokeContract(params: InvokeContractParams): Promise { + const { contractId, functionName, args = [], secretKey, network = 'testnet' } = params; + + if (network !== 'testnet') { + throw new Error('Transaction submission is only enabled on Testnet'); + } + + if (!secretKey.trim()) { + throw new Error('Secret key is required to submit a transaction'); + } + + // Guard: contract invocation submits to the network — block offline unless queued. + const { isWriteSafe } = await import('../offlineReadOnly'); + if (!isWriteSafe('contract invocation')) { + const { OfflineWriteError } = await import('../offlineReadOnly'); + throw new OfflineWriteError('contract invocation'); + } + + let keypair: StellarSdk.Keypair; + try { + keypair = StellarSdk.Keypair.fromSecret(secretKey.trim()); + } catch { + throw new Error('Invalid secret key'); + } + + const sourceAccount = keypair.publicKey(); + const server = getSorobanServer(network); + const transaction = await buildContractInvocationTransaction({ + contractId, + functionName, + args, + sourceAccount, + network, + }); + const prepared = await server.prepareTransaction(transaction); + + prepared.sign(keypair); + + const response = await server.sendTransaction(prepared); + + return { + hash: response.hash, + status: response.status, + errorResult: response.errorResult ? response.errorResult.toXDR('base64') : null, + diagnosticEvents: (response.diagnosticEvents || []).map((event) => event.toXDR('base64')), + }; +} diff --git a/src/lib/stellar/transactionBuilder.ts b/src/lib/stellar/transactionBuilder.ts new file mode 100644 index 00000000..1aef63bc --- /dev/null +++ b/src/lib/stellar/transactionBuilder.ts @@ -0,0 +1,172 @@ +import * as StellarSdk from '@stellar/stellar-sdk'; +import { NETWORKS, getServer, type NetworkName } from './networks.js'; +import { isValidPublicKey } from './addresses.js'; +import { validateMemo } from '../validation'; + +// ─── Transaction builder ────────────────────────────────────────────────────── + +export type OperationType = 'payment' | 'createAccount'; + +export interface PaymentOperation { + type: 'payment'; + destination: string; + amount: string; +} + +export interface CreateAccountOperation { + type: 'createAccount'; + destination: string; + startingBalance: string; +} + +export interface InvokeHostFunctionOperation { + type: 'invokeHostFunction'; + [key: string]: any; +} + +export type BuilderOperation = + | PaymentOperation + | CreateAccountOperation + | InvokeHostFunctionOperation; + +export interface TimeBounds { + minTime?: string | number; + maxTime?: string | number; +} + +export interface TransactionPreconditions { + ledgerBounds?: { + minLedger?: number | string; + maxLedger?: number | string; + }; + minSequence?: number | string; + minSequenceAge?: number | string; + minSequenceLedgerGap?: number | string; + extraSigners?: string[]; +} + +export interface BuildTransactionParams { + sourceAccount: string; + operations: BuilderOperation[]; + memo?: string; + baseFee: number; + timeBounds: TimeBounds; + preconditions?: TransactionPreconditions; + network: NetworkName; +} + +export async function buildTransaction( + params: BuildTransactionParams +): Promise { + const { sourceAccount, operations, memo, baseFee, timeBounds, preconditions, network } = params; + + // ── Fee-bump shortcut ────────────────────────────────────────────────────── + // A fee-bump must be the only operation and is built entirely from its own + // params — it doesn't need a source account or sequence number load. + if (operations.length === 1 && operations[0].type === 'feeBump') { + const op = operations[0] as any; + const { feeSource, baseFee: fbFee, innerTransaction } = op; + + if (!feeSource || !isValidPublicKey(feeSource)) { + throw new Error('Fee-bump: feeSource must be a valid Stellar public key.'); + } + const fee = parseInt(fbFee, 10); + if (!Number.isFinite(fee) || fee <= 0) { + throw new Error('Fee-bump: baseFee must be a positive integer (stroops).'); + } + if (!innerTransaction || typeof innerTransaction !== 'string' || innerTransaction.trim() === '') { + throw new Error('Fee-bump: innerTransaction XDR is required.'); + } + + try { + const innerTx = new StellarSdk.Transaction(innerTransaction.trim(), NETWORKS[network].passphrase); + return StellarSdk.TransactionBuilder.buildFeeBumpTransaction( + feeSource, + fee.toString(), + innerTx, + NETWORKS[network].passphrase, + ); + } catch (err) { + throw new Error(`Fee-bump: failed to wrap inner transaction — ${(err as Error).message}`); + } + } + + if (operations.some((op) => op.type === 'feeBump')) { + throw new Error('A fee-bump operation must be the only operation in the transaction.'); + } + // ────────────────────────────────────────────────────────────────────────── + + const server = getServer(network); + const account = await server.loadAccount(sourceAccount); + + const txBuilder = new StellarSdk.TransactionBuilder(account, { + fee: baseFee.toString(), + networkPassphrase: NETWORKS[network].passphrase, + }); + + if (timeBounds.minTime || timeBounds.maxTime) { + txBuilder.setTimeout( + timeBounds.maxTime ? parseInt(String(timeBounds.maxTime)) - Math.floor(Date.now() / 1000) : 0 + ); + } + + if (preconditions) { + if (preconditions.ledgerBounds) { + const minLedger = parseInt(String(preconditions.ledgerBounds.minLedger || 0), 10); + const maxLedger = parseInt(String(preconditions.ledgerBounds.maxLedger || 0), 10); + txBuilder.setLedgerbounds(minLedger, maxLedger); + } + + if (preconditions.minSequence !== undefined && preconditions.minSequence !== '') { + txBuilder.setMinAccountSequence(parseInt(String(preconditions.minSequence), 10)); + } + + if (preconditions.minSequenceAge !== undefined && preconditions.minSequenceAge !== '') { + txBuilder.setMinAccountSequenceAge(parseInt(String(preconditions.minSequenceAge), 10)); + } + + if (preconditions.minSequenceLedgerGap !== undefined && preconditions.minSequenceLedgerGap !== '') { + txBuilder.setMinAccountSequenceLedgerGap(parseInt(String(preconditions.minSequenceLedgerGap), 10)); + } + + if (preconditions.extraSigners && preconditions.extraSigners.length > 0) { + txBuilder.setExtraSigners(preconditions.extraSigners); + } + } + + operations.forEach((op) => { + if (op.type === 'payment') { + txBuilder.addOperation( + StellarSdk.Operation.payment({ + destination: op.destination, + asset: StellarSdk.Asset.native(), + amount: op.amount, + }) + ); + } else if (op.type === 'createAccount') { + txBuilder.addOperation( + StellarSdk.Operation.createAccount({ + destination: op.destination, + startingBalance: op.startingBalance, + }) + ); + } else if (op.type === 'invokeHostFunction') { + txBuilder.addOperation( + StellarSdk.Operation.invokeHostFunction({ + func: (op as any).func, + auth: (op as any).auth || [], + }) + ); + } + }); + + if (memo) { + const memoCheck = validateMemo(memo, 'text'); + if (!memoCheck.valid) { + throw new Error(memoCheck.errors[0]); + } + txBuilder.addMemo(StellarSdk.Memo.text(memo)); + } + + return txBuilder.build(); +}