diff --git a/README.md b/README.md index 136e270..4714396 100644 --- a/README.md +++ b/README.md @@ -224,6 +224,10 @@ The repository currently includes the following runnable examples: 69. **`138-account-merge-preflight`**: Inspect a Stellar account to determine merge readiness and identify blocking ledger states. 70. **`132-fee-bump-inspection`**: Decode and inspect fee-bump and normal transaction envelopes offline. 71. **`136-transaction-fee-estimation`**: Estimate minimum transaction fees using Horizon network fee statistics across operation sizes. +72. **`120-transaction-lifecycle-monitor`**: Monitor a Horizon transaction until confirmation, failure, timeout, or temporary rate limiting, with ledger, fee, operation-count, and result information. +73. **`121-account-history-pagination`**: Traverse an account's Horizon operation history page by page with configurable page size, record limits, operation filtering, cursor-safe traversal, and duplicate prevention. +74. **`122-order-book-inspection`**: Inspect a Stellar trading pair's bids, asks, best prices, spread, midpoint, configurable depth, and summarized liquidity. +75. **`123-trade-history-analysis`**: Retrieve historical trades for a Stellar pair, filter by time, and calculate high, low, average price, traded volume, and trade count. 72. **`124-liquidity-pool-inspection`**: Retrieve and analyze an existing Stellar liquidity pool, its reserves, shares, and fees. 73. **`125-liquidity-pool-simulation`**: Simulate deposit and withdrawal operations on a liquidity pool to estimate share and asset changes. 74. **`127-trustline-management`**: Inspect, create, update, and remove asset trustlines for a Stellar account. @@ -1041,3 +1045,37 @@ Contributions are welcome. To add or improve an example, read [CONTRIBUTING.md]( ## License This project is licensed under the MIT License. See [LICENSE](./LICENSE) for details. + +## Horizon Market and History Examples 120-123 + +Monitor a transaction through its Horizon lifecycle: + +```bash +npm run run-example 120-transaction-lifecycle-monitor +``` + +Set `TRANSACTION_HASH`, `POLL_INTERVAL_MS`, `POLL_TIMEOUT_MS`, and `JSON_OUTPUT=true` to configure non-interactive monitoring. If no hash is supplied, the example uses the latest Horizon transaction so it remains directly runnable. + +Paginate an account's historical operations: + +```bash +npm run run-example 121-account-history-pagination +``` + +Use `ACCOUNT_ID`, `PAGE_SIZE`, `MAX_RECORDS`, `OPERATION_TYPE`, and `JSON_OUTPUT=true` to configure account-history traversal. The example follows Horizon pagination links, prevents duplicate records, and stops at the requested maximum. + +Inspect current order-book depth: + +```bash +npm run run-example 122-order-book-inspection +``` + +Use `SELLING_ASSET`, `BUYING_ASSET`, `ORDER_BOOK_DEPTH`, and `JSON_OUTPUT=true`. Assets use `native`/`XLM` or `CODE:ISSUER`. Without an explicit pair, the example derives a recently traded pair from Horizon. + +Analyze historical trades: + +```bash +npm run run-example 123-trade-history-analysis +``` + +Use `SELLING_ASSET`, `BUYING_ASSET`, `TRADE_HISTORY_LIMIT`, `TRADE_FROM_TIME`, `TRADE_TO_TIME`, and `JSON_OUTPUT=true`. Time filters accept ISO-8601 values or Unix timestamps in seconds. Empty markets are reported as a valid zero-trade result. diff --git a/src/examples/120-transaction-lifecycle-monitor.ts b/src/examples/120-transaction-lifecycle-monitor.ts new file mode 100644 index 0000000..dd7f573 --- /dev/null +++ b/src/examples/120-transaction-lifecycle-monitor.ts @@ -0,0 +1,258 @@ +import { Horizon } from '@stellar/stellar-sdk'; + +const DEFAULT_HORIZON_URL = 'https://horizon-testnet.stellar.org'; +const DEFAULT_POLL_INTERVAL_MS = 2_000; +const DEFAULT_TIMEOUT_MS = 30_000; + +export interface TransactionLifecycleParams { + transactionHash?: string; + pollIntervalMs?: string | number; + timeoutMs?: string | number; + horizonUrl?: string; + json?: boolean | string; +} + +export type TransactionLifecycleStatus = 'pending' | 'confirmed' | 'failed' | 'timeout'; + +export interface TransactionLifecycleReport { + transactionHash: string; + status: TransactionLifecycleStatus; + ledgerSequence: number | null; + ledgerCloseTime: string | null; + successfulOperationCount: number; + feeCharged: string | null; + resultCode: string | null; + polls: number; + elapsedMs: number; +} + +export interface RawTransactionRecord { + hash?: string; + successful?: boolean; + ledger?: number; + ledger_attr?: number; + created_at?: string; + operation_count?: number; + fee_charged?: string; + result_code?: string; +} + +export interface MonitorOptions { + pollIntervalMs?: number; + timeoutMs?: number; + sleep?: (ms: number) => Promise; + now?: () => number; +} + +function wantsJson(params: TransactionLifecycleParams): boolean { + return ( + params.json === true || + params.json === 'true' || + process.env.JSON_OUTPUT === 'true' || + process.argv.includes('--json') + ); +} + +function readNonNegativeInt( + value: string | number | undefined, + fallback: number, + label: string, +): number { + if (value === undefined || value === '') { + return fallback; + } + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed < 0) { + throw new Error(`${label} must be a non-negative number.`); + } + return Math.floor(parsed); +} + +export function validateTransactionHash(value: string): string { + const hash = value.trim(); + if (!/^[0-9a-fA-F]{64}$/.test(hash)) { + throw new Error('Transaction hash must be exactly 64 hexadecimal characters.'); + } + return hash.toLowerCase(); +} + +export function getHorizonErrorStatus(error: unknown): number | undefined { + if (typeof error !== 'object' || error === null || !('response' in error)) { + return undefined; + } + const response = (error as { response?: { status?: unknown } }).response; + return typeof response?.status === 'number' ? response.status : undefined; +} + +export function getRetryAfterMs(error: unknown): number | undefined { + if (typeof error !== 'object' || error === null || !('response' in error)) { + return undefined; + } + const response = ( + error as { + response?: { + headers?: Record & { get?: (name: string) => string | null }; + }; + } + ).response; + const headers = response?.headers; + if (!headers) { + return undefined; + } + + const raw = + typeof headers.get === 'function' + ? headers.get('retry-after') + : (headers['retry-after'] ?? headers['Retry-After']); + + if (typeof raw !== 'string' && typeof raw !== 'number') { + return undefined; + } + + const seconds = Number(raw); + if (Number.isFinite(seconds)) { + return Math.max(0, seconds * 1000); + } + + const dateMs = Date.parse(String(raw)); + return Number.isNaN(dateMs) ? undefined : Math.max(0, dateMs - Date.now()); +} + +export function parseTransactionRecord( + transactionHash: string, + record: RawTransactionRecord, + polls: number, + elapsedMs: number, +): TransactionLifecycleReport { + const successful = record.successful === true; + const operationCount = Number(record.operation_count ?? 0); + + return { + transactionHash: record.hash ?? transactionHash, + status: successful ? 'confirmed' : 'failed', + ledgerSequence: record.ledger_attr ?? record.ledger ?? null, + ledgerCloseTime: record.created_at ?? null, + successfulOperationCount: + successful && Number.isFinite(operationCount) ? Math.max(0, operationCount) : 0, + feeCharged: record.fee_charged ?? null, + resultCode: record.result_code ?? (successful ? 'tx_success' : 'tx_failed'), + polls, + elapsedMs, + }; +} + +export async function monitorTransaction( + transactionHash: string, + fetchTransaction: () => Promise, + options: MonitorOptions = {}, +): Promise { + const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS; + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const sleep = + options.sleep ?? + ((ms: number) => + new Promise((resolve) => { + setTimeout(resolve, ms); + })); + const now = options.now ?? Date.now; + const startedAt = now(); + let polls = 0; + + while (now() - startedAt <= timeoutMs) { + polls += 1; + try { + const record = await fetchTransaction(); + return parseTransactionRecord(transactionHash, record, polls, now() - startedAt); + } catch (error) { + const status = getHorizonErrorStatus(error); + if (status !== 404 && status !== 429) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Horizon transaction lookup failed: ${message}`); + } + + const delay = + status === 429 + ? Math.max(pollIntervalMs, getRetryAfterMs(error) ?? pollIntervalMs) + : pollIntervalMs; + + if (now() - startedAt + delay > timeoutMs) { + break; + } + await sleep(delay); + } + } + + return { + transactionHash, + status: 'timeout', + ledgerSequence: null, + ledgerCloseTime: null, + successfulOperationCount: 0, + feeCharged: null, + resultCode: null, + polls, + elapsedMs: now() - startedAt, + }; +} + +async function discoverLatestTransactionHash(server: Horizon.Server): Promise { + const page = await server.transactions().order('desc').limit(1).call(); + const record = page.records[0] as unknown as RawTransactionRecord | undefined; + return record?.hash ?? null; +} + +export async function run(params: TransactionLifecycleParams = {}): Promise { + const horizonUrl = params.horizonUrl || process.env.HORIZON_URL || DEFAULT_HORIZON_URL; + const pollIntervalMs = readNonNegativeInt( + params.pollIntervalMs ?? process.env.POLL_INTERVAL_MS, + DEFAULT_POLL_INTERVAL_MS, + 'pollIntervalMs', + ); + const timeoutMs = readNonNegativeInt( + params.timeoutMs ?? process.env.POLL_TIMEOUT_MS, + DEFAULT_TIMEOUT_MS, + 'timeoutMs', + ); + const server = new Horizon.Server(horizonUrl); + const json = wantsJson(params); + + let input = params.transactionHash?.trim() || process.env.TRANSACTION_HASH?.trim(); + if (!input) { + if (!json) { + console.log('No transaction hash supplied; using the latest Horizon transaction.'); + } + input = (await discoverLatestTransactionHash(server)) ?? undefined; + } + if (!input) { + throw new Error('No transaction is currently available to monitor.'); + } + + const transactionHash = validateTransactionHash(input); + const report = await monitorTransaction( + transactionHash, + async () => + (await server + .transactions() + .transaction(transactionHash) + .call()) as unknown as RawTransactionRecord, + { pollIntervalMs, timeoutMs }, + ); + + if (json) { + console.log(JSON.stringify(report, null, 2)); + return; + } + + console.log('\n=== Stellar Transaction Lifecycle Monitor ==='); + console.log(`Transaction hash: ${report.transactionHash}`); + console.log(`Current status: ${report.status}`); + console.log(`Ledger sequence: ${report.ledgerSequence ?? 'Unavailable'}`); + console.log(`Ledger close time: ${report.ledgerCloseTime ?? 'Unavailable'}`); + console.log(`Successful operation count:${report.successfulOperationCount}`); + console.log(`Fee charged: ${report.feeCharged ?? 'Unavailable'}`); + console.log(`Result code: ${report.resultCode ?? 'Unavailable'}`); + console.log(`Poll attempts: ${report.polls}`); + if (report.status === 'timeout') { + console.log('The transaction was not available in Horizon before the polling timeout.'); + } +} diff --git a/src/examples/121-account-history-pagination.ts b/src/examples/121-account-history-pagination.ts new file mode 100644 index 0000000..2ea6230 --- /dev/null +++ b/src/examples/121-account-history-pagination.ts @@ -0,0 +1,255 @@ +import { Horizon, StrKey } from '@stellar/stellar-sdk'; + +const DEFAULT_HORIZON_URL = 'https://horizon-testnet.stellar.org'; +const DEFAULT_PAGE_SIZE = 10; +const DEFAULT_MAX_RECORDS = 50; +const MAX_HORIZON_LIMIT = 200; + +export interface AccountHistoryParams { + accountId?: string; + pageSize?: string | number; + maxRecords?: string | number; + operationType?: string; + horizonUrl?: string; + json?: boolean | string; +} + +export interface RawOperationRecord { + [key: string]: unknown; + id?: string; + paging_token?: string; + type?: string; + transaction_hash?: string; + ledger?: number; + ledger_attr?: number; + source_account?: string; + created_at?: string; +} + +export interface ParsedOperation { + id: string; + type: string; + transactionHash: string; + ledgerSequence: number | null; + sourceAccount: string; + timestamp: string; + pagingToken: string; +} + +export interface AccountHistoryReport { + accountId: string; + operationType: string | null; + pageSize: number; + maxRecords: number; + pagesProcessed: number; + recordsProcessed: number; + duplicatesSkipped: number; + operations: ParsedOperation[]; +} + +interface HorizonPage { + records: RawOperationRecord[]; + next: () => Promise; +} + +export interface PaginationOptions { + pageSize: number; + maxRecords: number; + operationType?: string; +} + +function wantsJson(params: AccountHistoryParams): boolean { + return ( + params.json === true || + params.json === 'true' || + process.env.JSON_OUTPUT === 'true' || + process.argv.includes('--json') + ); +} + +function normalizePositiveInt(value: string | number | undefined, fallback: number): number { + const parsed = typeof value === 'string' ? Number.parseInt(value.trim(), 10) : value; + if (parsed === undefined || parsed === null || !Number.isFinite(parsed) || parsed <= 0) { + return fallback; + } + return Math.trunc(parsed); +} + +export function normalizePageSize( + value: string | number | undefined, + fallback = DEFAULT_PAGE_SIZE, +): number { + return Math.min(normalizePositiveInt(value, fallback), MAX_HORIZON_LIMIT); +} + +export function normalizeMaxRecords( + value: string | number | undefined, + fallback = DEFAULT_MAX_RECORDS, +): number { + return normalizePositiveInt(value, fallback); +} + +export function validateAccountId(value: string): string { + const accountId = value.trim(); + if (!StrKey.isValidEd25519PublicKey(accountId)) { + throw new Error('Account ID must be a valid Stellar G... public key.'); + } + return accountId; +} + +export function parseOperationRecord(record: RawOperationRecord): ParsedOperation { + return { + id: String(record.id ?? record.paging_token ?? ''), + type: String(record.type ?? 'unknown'), + transactionHash: String(record.transaction_hash ?? ''), + ledgerSequence: + typeof (record.ledger_attr ?? record.ledger) === 'number' + ? Number(record.ledger_attr ?? record.ledger) + : null, + sourceAccount: String(record.source_account ?? ''), + timestamp: String(record.created_at ?? ''), + pagingToken: String(record.paging_token ?? record.id ?? ''), + }; +} + +export async function paginateAccountOperations( + fetchFirstPage: () => Promise, + options: PaginationOptions, +): Promise> { + const operationType = options.operationType?.trim().toLowerCase() || undefined; + const seen = new Set(); + const operations: ParsedOperation[] = []; + let pagesProcessed = 0; + let duplicatesSkipped = 0; + let lastCursor = ''; + let page = await fetchFirstPage(); + + while (page.records.length > 0 && operations.length < options.maxRecords) { + pagesProcessed += 1; + + for (const raw of page.records) { + const parsed = parseOperationRecord(raw); + const identity = parsed.id || parsed.pagingToken; + if (identity && seen.has(identity)) { + duplicatesSkipped += 1; + continue; + } + if (identity) { + seen.add(identity); + } + + if (operationType && parsed.type.toLowerCase() !== operationType) { + continue; + } + + operations.push(parsed); + if (operations.length >= options.maxRecords) { + break; + } + } + + if (operations.length >= options.maxRecords) { + break; + } + + const cursor = String(page.records[page.records.length - 1]?.paging_token ?? ''); + if (cursor && cursor === lastCursor) { + break; + } + lastCursor = cursor; + page = await page.next(); + } + + return { + operationType: operationType ?? null, + pageSize: options.pageSize, + maxRecords: options.maxRecords, + pagesProcessed, + recordsProcessed: operations.length, + duplicatesSkipped, + operations, + }; +} + +async function discoverActiveAccount(server: Horizon.Server): Promise { + const page = await server.operations().order('desc').limit(20).call(); + for (const item of page.records as unknown as RawOperationRecord[]) { + const source = typeof item.source_account === 'string' ? item.source_account : ''; + if (source && StrKey.isValidEd25519PublicKey(source)) { + return source; + } + } + return null; +} + +export async function run(params: AccountHistoryParams = {}): Promise { + const horizonUrl = params.horizonUrl || process.env.HORIZON_URL || DEFAULT_HORIZON_URL; + const pageSize = normalizePageSize(params.pageSize ?? process.env.PAGE_SIZE, DEFAULT_PAGE_SIZE); + const maxRecords = normalizeMaxRecords( + params.maxRecords ?? process.env.MAX_RECORDS, + DEFAULT_MAX_RECORDS, + ); + const operationType = + params.operationType?.trim() || process.env.OPERATION_TYPE?.trim() || undefined; + const server = new Horizon.Server(horizonUrl); + const json = wantsJson(params); + + let input = params.accountId?.trim() || process.env.ACCOUNT_ID?.trim(); + if (!input) { + if (!json) { + console.log('No account supplied; discovering a recently active account.'); + } + input = (await discoverActiveAccount(server)) ?? undefined; + } + if (!input) { + throw new Error('Could not discover an account with operation history.'); + } + + const accountId = validateAccountId(input); + + try { + const reportBody = await paginateAccountOperations( + async () => + (await server + .operations() + .forAccount(accountId) + .order('asc') + .limit(pageSize) + .call()) as unknown as HorizonPage, + { pageSize, maxRecords, operationType }, + ); + const report: AccountHistoryReport = { accountId, ...reportBody }; + + if (json) { + console.log(JSON.stringify(report, null, 2)); + return; + } + + console.log('\n=== Horizon Account History Pagination ==='); + console.log(`Account: ${accountId}`); + console.log(`Page size: ${pageSize}`); + console.log(`Maximum records: ${maxRecords}`); + console.log(`Operation filter: ${report.operationType ?? 'None'}`); + console.log(`Pages processed: ${report.pagesProcessed}`); + console.log(`Records processed: ${report.recordsProcessed}`); + console.log(`Duplicates skipped: ${report.duplicatesSkipped}`); + + if (report.operations.length === 0) { + console.log('\nNo matching operation history was found for this account.'); + return; + } + + for (const operation of report.operations) { + console.log('\n--- Operation ---'); + console.log(`Operation ID: ${operation.id}`); + console.log(`Type: ${operation.type}`); + console.log(`Transaction hash: ${operation.transactionHash || 'Unavailable'}`); + console.log(`Ledger sequence: ${operation.ledgerSequence ?? 'Unavailable'}`); + console.log(`Source account: ${operation.sourceAccount || 'Unavailable'}`); + console.log(`Timestamp: ${operation.timestamp || 'Unavailable'}`); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Unable to retrieve account history from Horizon: ${message}`); + } +} diff --git a/src/examples/122-order-book-inspection.ts b/src/examples/122-order-book-inspection.ts new file mode 100644 index 0000000..70a5dc6 --- /dev/null +++ b/src/examples/122-order-book-inspection.ts @@ -0,0 +1,288 @@ +import { Asset, Horizon } from '@stellar/stellar-sdk'; + +const DEFAULT_HORIZON_URL = 'https://horizon-testnet.stellar.org'; +const DEFAULT_DEPTH = 10; +const MAX_DEPTH = 200; + +export interface OrderBookParams { + sellingAsset?: string; + buyingAsset?: string; + depth?: string | number; + horizonUrl?: string; + json?: boolean | string; +} + +export interface RawOrderBookLevel { + price?: string; + amount?: string; + price_r?: { n?: number | string; d?: number | string }; +} + +export interface OrderBookLevel { + price: number; + amount: number; + counterValue: number; +} + +export interface OrderBookAnalysis { + bestBid: number | null; + bestAsk: number | null; + spread: number | null; + midMarketPrice: number | null; + spreadPercentage: number | null; + bidQuantity: number; + askQuantity: number; + bidPriceLevels: number; + askPriceLevels: number; + priceLevels: number; + totalBidLiquidity: number; + totalAskLiquidity: number; + totalBidCounterValue: number; + totalAskCounterValue: number; + bids: OrderBookLevel[]; + asks: OrderBookLevel[]; +} + +export interface RawOrderBookResponse { + bids?: RawOrderBookLevel[]; + asks?: RawOrderBookLevel[]; +} + +function wantsJson(params: OrderBookParams): boolean { + return ( + params.json === true || + params.json === 'true' || + process.env.JSON_OUTPUT === 'true' || + process.argv.includes('--json') + ); +} + +export function normalizeDepth(value?: string | number): number { + const parsed = typeof value === 'string' ? Number.parseInt(value.trim(), 10) : value; + if (parsed === undefined || parsed === null || !Number.isFinite(parsed) || parsed <= 0) { + return DEFAULT_DEPTH; + } + return Math.min(Math.trunc(parsed), MAX_DEPTH); +} + +export function parseTradingAsset(value: string, label = 'asset'): Asset { + const input = value.trim(); + if (!input) { + throw new Error(`Missing ${label}. Use "native" or "CODE:ISSUER".`); + } + if (input.toLowerCase() === 'native' || input.toUpperCase() === 'XLM') { + return Asset.native(); + } + + const separator = input.indexOf(':'); + if (separator <= 0 || separator === input.length - 1) { + throw new Error(`Invalid ${label} "${input}". Issued assets require CODE:ISSUER.`); + } + + try { + return new Asset(input.slice(0, separator).trim(), input.slice(separator + 1).trim()); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Invalid ${label} "${input}": ${message}`); + } +} + +export function describeTradingAsset(asset: Asset): string { + return asset.isNative() ? 'XLM' : `${asset.getCode()}:${asset.getIssuer()}`; +} + +export function parseOrderBookLevel(level: RawOrderBookLevel): OrderBookLevel { + const numerator = Number(level.price_r?.n); + const denominator = Number(level.price_r?.d); + const rationalPrice = + Number.isFinite(numerator) && Number.isFinite(denominator) && denominator !== 0 + ? numerator / denominator + : Number.NaN; + const decimalPrice = Number.parseFloat(level.price ?? '0'); + const price = Number.isFinite(rationalPrice) + ? rationalPrice + : Number.isFinite(decimalPrice) + ? decimalPrice + : 0; + const amountValue = Number.parseFloat(level.amount ?? '0'); + const amount = Number.isFinite(amountValue) ? amountValue : 0; + + return { + price, + amount, + counterValue: amount * price, + }; +} + +export function analyzeOrderBook( + rawBids: RawOrderBookLevel[], + rawAsks: RawOrderBookLevel[], + depth: number, +): OrderBookAnalysis { + const bids = rawBids + .map(parseOrderBookLevel) + .sort((a, b) => b.price - a.price) + .slice(0, depth); + const asks = rawAsks + .map(parseOrderBookLevel) + .sort((a, b) => a.price - b.price) + .slice(0, depth); + const bestBid = bids[0]?.price ?? null; + const bestAsk = asks[0]?.price ?? null; + const spread = bestBid !== null && bestAsk !== null ? bestAsk - bestBid : null; + const midMarketPrice = bestBid !== null && bestAsk !== null ? (bestBid + bestAsk) / 2 : null; + const spreadPercentage = + spread !== null && midMarketPrice !== null && midMarketPrice !== 0 + ? (spread / midMarketPrice) * 100 + : null; + + return { + bestBid, + bestAsk, + spread, + midMarketPrice, + spreadPercentage, + bidQuantity: bids[0]?.amount ?? 0, + askQuantity: asks[0]?.amount ?? 0, + bidPriceLevels: bids.length, + askPriceLevels: asks.length, + priceLevels: bids.length + asks.length, + totalBidLiquidity: bids.reduce((sum, level) => sum + level.amount, 0), + totalAskLiquidity: asks.reduce((sum, level) => sum + level.amount, 0), + totalBidCounterValue: bids.reduce((sum, level) => sum + level.counterValue, 0), + totalAskCounterValue: asks.reduce((sum, level) => sum + level.counterValue, 0), + bids, + asks, + }; +} + +export async function fetchOrderBook( + server: Horizon.Server, + selling: Asset, + buying: Asset, + depth: number, +): Promise { + const builder = server.orderbook(selling, buying) as unknown as { + limit: (limit: number) => { call: () => Promise }; + }; + return builder.limit(depth).call(); +} + +function assetFromTradeSide(type: unknown, code: unknown, issuer: unknown): Asset | null { + if (type === 'native') { + return Asset.native(); + } + return typeof code === 'string' && typeof issuer === 'string' ? new Asset(code, issuer) : null; +} + +async function discoverTradingPair( + server: Horizon.Server, +): Promise<{ selling: Asset; buying: Asset } | null> { + const page = await server.trades().order('desc').limit(1).call(); + const record = page.records[0] as unknown as Record | undefined; + if (!record) { + return null; + } + const selling = assetFromTradeSide( + record.base_asset_type, + record.base_asset_code, + record.base_asset_issuer, + ); + const buying = assetFromTradeSide( + record.counter_asset_type, + record.counter_asset_code, + record.counter_asset_issuer, + ); + return selling && buying ? { selling, buying } : null; +} + +export async function run(params: OrderBookParams = {}): Promise { + const horizonUrl = params.horizonUrl || process.env.HORIZON_URL || DEFAULT_HORIZON_URL; + const depth = normalizeDepth(params.depth ?? process.env.ORDER_BOOK_DEPTH); + const server = new Horizon.Server(horizonUrl); + const json = wantsJson(params); + + const sellingInput = params.sellingAsset?.trim() || process.env.SELLING_ASSET?.trim(); + const buyingInput = params.buyingAsset?.trim() || process.env.BUYING_ASSET?.trim(); + + let selling: Asset; + let buying: Asset; + + if (sellingInput && buyingInput) { + selling = parseTradingAsset(sellingInput, 'selling asset'); + buying = parseTradingAsset(buyingInput, 'buying asset'); + } else if (!sellingInput && !buyingInput) { + if (!json) { + console.log('No asset pair supplied; using a recently traded pair.'); + } + const discovered = await discoverTradingPair(server); + if (!discovered) { + throw new Error('No recent Stellar trading pair could be discovered.'); + } + selling = discovered.selling; + buying = discovered.buying; + } else { + throw new Error('Both sellingAsset and buyingAsset are required when specifying a pair.'); + } + + if (selling.equals(buying)) { + throw new Error('Selling and buying assets must be different.'); + } + + let raw: RawOrderBookResponse; + try { + raw = await fetchOrderBook(server, selling, buying, depth); + } catch (error) { + const status = + typeof error === 'object' && error !== null && 'response' in error + ? (error as { response?: { status?: number } }).response?.status + : undefined; + if (status === 400 || status === 404 || status === 422) { + throw new Error('Horizon rejected the asset pair. Check the asset code and issuer.'); + } + throw error; + } + + const analysis = analyzeOrderBook(raw.bids ?? [], raw.asks ?? [], depth); + const report = { + tradingPair: `${describeTradingAsset(selling)} / ${describeTradingAsset(buying)}`, + depth, + ...analysis, + }; + + if (json) { + console.log(JSON.stringify(report, null, 2)); + return; + } + + console.log('\n=== Stellar Order Book Inspection ==='); + console.log(`Trading pair: ${report.tradingPair}`); + console.log(`Displayed depth: ${depth} levels per side`); + console.log('Bids represent demand to buy the selling/base asset.'); + console.log('Asks represent offers to sell the selling/base asset.'); + + if (analysis.priceLevels === 0) { + console.log('\nNo active orders were found for this market.'); + return; + } + + console.log(`Best bid: ${analysis.bestBid ?? 'Unavailable'}`); + console.log(`Best ask: ${analysis.bestAsk ?? 'Unavailable'}`); + console.log(`Bid/ask spread: ${analysis.spread ?? 'Unavailable'}`); + console.log(`Mid-market price: ${analysis.midMarketPrice ?? 'Unavailable'}`); + console.log(`Spread percentage: ${analysis.spreadPercentage?.toFixed(4) ?? 'Unavailable'}%`); + console.log(`Best bid quantity: ${analysis.bidQuantity}`); + console.log(`Best ask quantity: ${analysis.askQuantity}`); + console.log(`Price levels: ${analysis.priceLevels}`); + console.log(`Bid liquidity: ${analysis.totalBidLiquidity}`); + console.log(`Ask liquidity: ${analysis.totalAskLiquidity}`); + + console.log('\nBids:'); + analysis.bids.forEach((level, index) => { + console.log(` ${index + 1}. price=${level.price} amount=${level.amount}`); + }); + console.log('\nAsks:'); + analysis.asks.forEach((level, index) => { + console.log(` ${index + 1}. price=${level.price} amount=${level.amount}`); + }); +} diff --git a/src/examples/123-trade-history-analysis.ts b/src/examples/123-trade-history-analysis.ts new file mode 100644 index 0000000..fef88eb --- /dev/null +++ b/src/examples/123-trade-history-analysis.ts @@ -0,0 +1,240 @@ +import { Asset, Horizon } from '@stellar/stellar-sdk'; +import { + describeAsset, + parseAssetInput, + parseTradeRecord, + summarizeTrades, +} from './55-trade-history'; +import type { ParsedTrade, RawTradeRecord } from './55-trade-history'; + +const DEFAULT_HORIZON_URL = 'https://horizon-testnet.stellar.org'; +const DEFAULT_LIMIT = 20; +const MAX_LIMIT = 200; + +export interface TradeHistoryAnalysisParams { + sellingAsset?: string; + buyingAsset?: string; + limit?: string | number; + fromTime?: string; + toTime?: string; + horizonUrl?: string; + json?: boolean | string; +} + +export interface TradeStatistics { + highestTradePrice: number; + lowestTradePrice: number; + averageTradePrice: number; + totalTradedVolume: number; + totalCounterVolume: number; + numberOfTrades: number; +} + +function wantsJson(params: TradeHistoryAnalysisParams): boolean { + return ( + params.json === true || + params.json === 'true' || + process.env.JSON_OUTPUT === 'true' || + process.argv.includes('--json') + ); +} + +export function normalizeTradeLimit(value?: string | number): number { + const parsed = typeof value === 'string' ? Number.parseInt(value.trim(), 10) : value; + if (parsed === undefined || parsed === null || !Number.isFinite(parsed) || parsed <= 0) { + return DEFAULT_LIMIT; + } + return Math.min(Math.trunc(parsed), MAX_LIMIT); +} + +export function parseTimeFilter(value: string | undefined, label: string): number | undefined { + if (!value?.trim()) { + return undefined; + } + const normalized = value.trim(); + const numeric = Number(normalized); + const timestampMs = Number.isFinite(numeric) ? numeric * 1000 : Date.parse(normalized); + if (!Number.isFinite(timestampMs)) { + throw new Error(`${label} must be a Unix timestamp in seconds or a valid ISO-8601 date.`); + } + return timestampMs; +} + +export function filterTradesByTime( + trades: ParsedTrade[], + fromMs?: number, + toMs?: number, +): ParsedTrade[] { + if (fromMs !== undefined && toMs !== undefined && fromMs > toMs) { + throw new Error('fromTime must be earlier than or equal to toTime.'); + } + return trades.filter((trade) => { + const time = Date.parse(trade.ledgerCloseTime); + if (Number.isNaN(time)) { + return false; + } + return (fromMs === undefined || time >= fromMs) && (toMs === undefined || time <= toMs); + }); +} + +export function calculateTradeStatistics(trades: ParsedTrade[]): TradeStatistics { + const summary = summarizeTrades(trades); + return { + highestTradePrice: summary.highestPrice, + lowestTradePrice: summary.lowestPrice, + averageTradePrice: summary.averagePrice, + totalTradedVolume: summary.totalBaseVolume, + totalCounterVolume: summary.totalCounterVolume, + numberOfTrades: summary.tradeCount, + }; +} + +export function parseHistoricalTrade(record: RawTradeRecord): ParsedTrade { + return parseTradeRecord(record); +} + +export async function fetchHistoricalTrades( + server: Horizon.Server, + selling: Asset, + buying: Asset, + limit: number, +): Promise { + const page = await server + .trades() + .forAssetPair(selling, buying) + .order('desc') + .limit(limit) + .call(); + return page.records as unknown as RawTradeRecord[]; +} + +function assetFromTradeSide(type: unknown, code: unknown, issuer: unknown): Asset | null { + if (type === 'native') { + return Asset.native(); + } + return typeof code === 'string' && typeof issuer === 'string' ? new Asset(code, issuer) : null; +} + +async function discoverTradingPair( + server: Horizon.Server, +): Promise<{ selling: Asset; buying: Asset } | null> { + const page = await server.trades().order('desc').limit(1).call(); + const record = page.records[0] as unknown as Record | undefined; + if (!record) { + return null; + } + const selling = assetFromTradeSide( + record.base_asset_type, + record.base_asset_code, + record.base_asset_issuer, + ); + const buying = assetFromTradeSide( + record.counter_asset_type, + record.counter_asset_code, + record.counter_asset_issuer, + ); + return selling && buying ? { selling, buying } : null; +} + +export async function run(params: TradeHistoryAnalysisParams = {}): Promise { + const horizonUrl = params.horizonUrl || process.env.HORIZON_URL || DEFAULT_HORIZON_URL; + const limit = normalizeTradeLimit(params.limit ?? process.env.TRADE_HISTORY_LIMIT); + const fromMs = parseTimeFilter(params.fromTime ?? process.env.TRADE_FROM_TIME, 'fromTime'); + const toMs = parseTimeFilter(params.toTime ?? process.env.TRADE_TO_TIME, 'toTime'); + const json = wantsJson(params); + const server = new Horizon.Server(horizonUrl); + const sellingInput = params.sellingAsset?.trim() || process.env.SELLING_ASSET?.trim(); + const buyingInput = params.buyingAsset?.trim() || process.env.BUYING_ASSET?.trim(); + + let selling: Asset; + let buying: Asset; + + if (sellingInput && buyingInput) { + selling = parseAssetInput(sellingInput, 'selling asset'); + buying = parseAssetInput(buyingInput, 'buying asset'); + } else if (!sellingInput && !buyingInput) { + if (!json) { + console.log('No asset pair supplied; using a recently traded pair.'); + } + const discovered = await discoverTradingPair(server); + if (!discovered) { + throw new Error('No recent Stellar trading pair could be discovered.'); + } + selling = discovered.selling; + buying = discovered.buying; + } else { + throw new Error('Both sellingAsset and buyingAsset are required when specifying a pair.'); + } + + if (selling.equals(buying)) { + throw new Error('Selling and buying assets must be different.'); + } + + let rawTrades: RawTradeRecord[] = []; + try { + const retrievalLimit = fromMs !== undefined || toMs !== undefined ? MAX_LIMIT : limit; + rawTrades = await fetchHistoricalTrades(server, selling, buying, retrievalLimit); + } catch (error) { + const status = + typeof error === 'object' && error !== null && 'response' in error + ? (error as { response?: { status?: number } }).response?.status + : undefined; + if (status === 404) { + rawTrades = []; + } else if (status === 400 || status === 422) { + throw new Error('Horizon rejected the asset pair. Check the asset code and issuer.'); + } else { + throw error; + } + } + + const parsed = rawTrades.map(parseHistoricalTrade); + const filtered = filterTradesByTime(parsed, fromMs, toMs).slice(0, limit); + const statistics = calculateTradeStatistics(filtered); + const report = { + tradingPair: `${describeAsset(selling)} / ${describeAsset(buying)}`, + filters: { + fromTime: fromMs === undefined ? null : new Date(fromMs).toISOString(), + toTime: toMs === undefined ? null : new Date(toMs).toISOString(), + limit, + }, + trades: filtered.map((trade) => ({ + timestamp: trade.ledgerCloseTime, + price: trade.price, + baseAssetAmount: trade.baseAmount, + counterAssetAmount: trade.counterAmount, + tradeType: trade.tradeType, + id: trade.id, + })), + statistics, + }; + + if (json) { + console.log(JSON.stringify(report, null, 2)); + return; + } + + console.log('\n=== Stellar Trade History Analysis ==='); + console.log(`Trading pair: ${report.tradingPair}`); + console.log(`Trades: ${statistics.numberOfTrades}`); + + if (filtered.length === 0) { + console.log('No trade history matched this asset pair and time window.'); + return; + } + + filtered.forEach((trade, index) => { + console.log(`\n[${index + 1}] ${trade.ledgerCloseTime}`); + console.log(`Trade price: ${trade.price}`); + console.log(`Base amount: ${trade.baseAmount}`); + console.log(`Counter amount: ${trade.counterAmount}`); + console.log(`Trade type: ${trade.tradeType || 'Unavailable'}`); + }); + + console.log('\nMarket statistics:'); + console.log(`Highest price: ${statistics.highestTradePrice}`); + console.log(`Lowest price: ${statistics.lowestTradePrice}`); + console.log(`Average price: ${statistics.averageTradePrice}`); + console.log(`Total traded volume: ${statistics.totalTradedVolume}`); + console.log(`Number of trades: ${statistics.numberOfTrades}`); +} diff --git a/src/runner/catalog.ts b/src/runner/catalog.ts index 17c701f..4c57a73 100644 --- a/src/runner/catalog.ts +++ b/src/runner/catalog.ts @@ -1283,6 +1283,143 @@ export const examples: Record = { }, ], }, + '120-transaction-lifecycle-monitor': { + name: '120-transaction-lifecycle-monitor', + description: + 'Poll Horizon for a transaction until it confirms, fails, times out, or is temporarily rate limited', + run: loadExample('../examples/120-transaction-lifecycle-monitor'), + params: [ + { + type: 'input', + name: 'transactionHash', + message: 'Transaction hash (blank uses the latest Horizon transaction):', + }, + { + type: 'input', + name: 'pollIntervalMs', + message: 'Polling interval in milliseconds:', + default: '2000', + }, + { + type: 'input', + name: 'timeoutMs', + message: 'Polling timeout in milliseconds:', + default: '30000', + }, + { + type: 'confirm', + name: 'json', + message: 'Output JSON?', + default: false, + }, + ], + }, + '121-account-history-pagination': { + name: '121-account-history-pagination', + description: + 'Paginate an account operation history safely with record limits, operation filters, and duplicate prevention', + run: loadExample('../examples/121-account-history-pagination'), + params: [ + { + type: 'input', + name: 'accountId', + message: 'Account ID (blank discovers a recently active account):', + }, + { + type: 'input', + name: 'pageSize', + message: 'Horizon page size:', + default: '10', + }, + { + type: 'input', + name: 'maxRecords', + message: 'Maximum records to process:', + default: '50', + }, + { + type: 'input', + name: 'operationType', + message: 'Optional operation type filter (for example payment):', + }, + { + type: 'confirm', + name: 'json', + message: 'Output JSON?', + default: false, + }, + ], + }, + '122-order-book-inspection': { + name: '122-order-book-inspection', + description: + 'Inspect Stellar order-book bids, asks, spread, midpoint, market depth, and available liquidity', + run: loadExample('../examples/122-order-book-inspection'), + params: [ + { + type: 'input', + name: 'sellingAsset', + message: 'Selling/base asset (native or CODE:ISSUER; blank auto-discovers a pair):', + }, + { + type: 'input', + name: 'buyingAsset', + message: 'Buying/counter asset (native or CODE:ISSUER; blank auto-discovers a pair):', + }, + { + type: 'input', + name: 'depth', + message: 'Order-book depth per side:', + default: '10', + }, + { + type: 'confirm', + name: 'json', + message: 'Output JSON?', + default: false, + }, + ], + }, + '123-trade-history-analysis': { + name: '123-trade-history-analysis', + description: + 'Retrieve historical Stellar trades and calculate price, volume, activity, and time-window statistics', + run: loadExample('../examples/123-trade-history-analysis'), + params: [ + { + type: 'input', + name: 'sellingAsset', + message: 'Selling/base asset (native or CODE:ISSUER; blank auto-discovers a pair):', + }, + { + type: 'input', + name: 'buyingAsset', + message: 'Buying/counter asset (native or CODE:ISSUER; blank auto-discovers a pair):', + }, + { + type: 'input', + name: 'limit', + message: 'Maximum trade records to display:', + default: '20', + }, + { + type: 'input', + name: 'fromTime', + message: 'Optional start time (ISO-8601 or Unix seconds):', + }, + { + type: 'input', + name: 'toTime', + message: 'Optional end time (ISO-8601 or Unix seconds):', + }, + { + type: 'confirm', + name: 'json', + message: 'Output JSON?', + default: false, + }, + ], + }, '124-liquidity-pool-inspection': { name: '124-liquidity-pool-inspection', description: 'Retrieve and analyze a Stellar liquidity pool state and reserves', diff --git a/tests/horizon-examples-120-123.test.ts b/tests/horizon-examples-120-123.test.ts new file mode 100644 index 0000000..25b57f3 --- /dev/null +++ b/tests/horizon-examples-120-123.test.ts @@ -0,0 +1,287 @@ +import fs from 'fs'; +import path from 'path'; + +import { + getRetryAfterMs, + monitorTransaction, + validateTransactionHash, +} from '../src/examples/120-transaction-lifecycle-monitor'; +import { + normalizeMaxRecords, + normalizePageSize, + paginateAccountOperations, + validateAccountId, +} from '../src/examples/121-account-history-pagination'; +import { + analyzeOrderBook, + normalizeDepth, + parseTradingAsset, +} from '../src/examples/122-order-book-inspection'; +import { + calculateTradeStatistics, + filterTradesByTime, + parseHistoricalTrade, + parseTimeFilter, +} from '../src/examples/123-trade-history-analysis'; + +describe('120 transaction lifecycle monitor', () => { + test('validates transaction hashes', () => { + expect(validateTransactionHash('A'.repeat(64))).toBe('a'.repeat(64)); + expect(() => validateTransactionHash('not-a-hash')).toThrow('64 hexadecimal'); + }); + + test('keeps polling unavailable transactions and returns confirmation', async () => { + let attempt = 0; + const report = await monitorTransaction( + 'a'.repeat(64), + async () => { + attempt += 1; + if (attempt === 1) { + throw { response: { status: 404 } }; + } + return { + hash: 'a'.repeat(64), + successful: true, + ledger_attr: 123, + created_at: '2026-01-01T00:00:00Z', + operation_count: 2, + fee_charged: '200', + }; + }, + { pollIntervalMs: 0, timeoutMs: 100, sleep: async () => undefined }, + ); + + expect(report.status).toBe('confirmed'); + expect(report.polls).toBe(2); + expect(report.ledgerSequence).toBe(123); + expect(report.successfulOperationCount).toBe(2); + }); + + test('reports failed transactions and parses retry-after', async () => { + const report = await monitorTransaction( + 'b'.repeat(64), + async () => ({ successful: false, operation_count: 3 }), + { pollIntervalMs: 0, timeoutMs: 100 }, + ); + expect(report.status).toBe('failed'); + expect(report.resultCode).toBe('tx_failed'); + expect(getRetryAfterMs({ response: { headers: { 'retry-after': '2' } } })).toBe(2000); + }); +}); + +describe('121 account history pagination', () => { + test('validates account IDs', () => { + expect(() => validateAccountId('invalid')).toThrow('valid Stellar'); + }); + + test('caps page size but allows maximum records to span multiple pages', () => { + expect(normalizePageSize(500)).toBe(200); + expect(normalizeMaxRecords(500)).toBe(500); + }); + + test('traverses pages, filters operations, and removes duplicates', async () => { + const page2 = { + records: [ + { + id: '2', + paging_token: '2', + type: 'payment', + transaction_hash: 'tx2', + source_account: 'GSECOND', + created_at: '2026-01-02T00:00:00Z', + }, + { + id: '3', + paging_token: '3', + type: 'create_account', + transaction_hash: 'tx3', + }, + ], + next: async () => ({ + records: [], + next: async () => { + throw new Error('No further page expected'); + }, + }), + }; + const page1 = { + records: [ + { + id: '1', + paging_token: '1', + type: 'payment', + transaction_hash: 'tx1', + }, + { + id: '2', + paging_token: '2', + type: 'payment', + transaction_hash: 'tx2', + }, + ], + next: async () => page2, + }; + + const report = await paginateAccountOperations(async () => page1, { + pageSize: 2, + maxRecords: 10, + operationType: 'payment', + }); + + expect(report.pagesProcessed).toBe(2); + expect(report.recordsProcessed).toBe(2); + expect(report.duplicatesSkipped).toBe(1); + expect(report.operations.map((operation) => operation.id)).toEqual(['1', '2']); + }); + + test('respects maximum record count', async () => { + const page = { + records: [ + { id: '1', paging_token: '1', type: 'payment' }, + { id: '2', paging_token: '2', type: 'payment' }, + ], + next: async () => ({ + records: [], + next: async () => { + throw new Error('No further page expected'); + }, + }), + }; + const report = await paginateAccountOperations(async () => page, { + pageSize: 2, + maxRecords: 1, + }); + expect(report.recordsProcessed).toBe(1); + }); +}); + +describe('122 order book inspection', () => { + test('validates assets and depth', () => { + expect(parseTradingAsset('native').isNative()).toBe(true); + expect(() => parseTradingAsset('USD')).toThrow('CODE:ISSUER'); + expect(normalizeDepth('500')).toBe(200); + }); + + test('calculates best prices, spread, midpoint, depth and liquidity', () => { + const analysis = analyzeOrderBook( + [ + { price: '2.0', amount: '3' }, + { price: '1.9', amount: '5' }, + ], + [ + { price: '2.2', amount: '4' }, + { price: '2.1', amount: '6' }, + ], + 1, + ); + + expect(analysis.bestBid).toBe(2); + expect(analysis.bestAsk).toBe(2.1); + expect(analysis.spread).toBeCloseTo(0.1); + expect(analysis.midMarketPrice).toBeCloseTo(2.05); + expect(analysis.bidQuantity).toBe(3); + expect(analysis.askQuantity).toBe(6); + expect(analysis.priceLevels).toBe(2); + expect(analysis.totalBidLiquidity).toBe(3); + expect(analysis.totalAskLiquidity).toBe(6); + }); + + test('handles an empty order book', () => { + const analysis = analyzeOrderBook([], [], 10); + expect(analysis.bestBid).toBeNull(); + expect(analysis.bestAsk).toBeNull(); + expect(analysis.priceLevels).toBe(0); + }); +}); + +describe('123 trade history analysis', () => { + const trade1 = parseHistoricalTrade({ + id: '1-0', + ledger_close_time: '2026-01-01T00:00:00Z', + trade_type: 'orderbook', + base_amount: '2', + counter_amount: '4', + price: { n: 2, d: 1 }, + base_asset_type: 'native', + counter_asset_type: 'credit_alphanum4', + counter_asset_code: 'USD', + counter_asset_issuer: 'GISSUER', + }); + const trade2 = parseHistoricalTrade({ + id: '2-0', + ledger_close_time: '2026-01-02T00:00:00Z', + trade_type: 'liquidity_pool', + base_amount: '4', + counter_amount: '12', + price: { n: 3, d: 1 }, + base_asset_type: 'native', + counter_asset_type: 'credit_alphanum4', + counter_asset_code: 'USD', + counter_asset_issuer: 'GISSUER', + }); + + test('parses trades and calculates price and volume statistics', () => { + const statistics = calculateTradeStatistics([trade1, trade2]); + expect(trade1.price).toBe(2); + expect(statistics.highestTradePrice).toBe(3); + expect(statistics.lowestTradePrice).toBe(2); + expect(statistics.averageTradePrice).toBe(2.5); + expect(statistics.totalTradedVolume).toBe(6); + expect(statistics.numberOfTrades).toBe(2); + }); + + test('filters trades by time', () => { + const from = parseTimeFilter('2026-01-02T00:00:00Z', 'fromTime'); + expect(filterTradesByTime([trade1, trade2], from)).toEqual([trade2]); + }); + + test('handles an empty history', () => { + expect(calculateTradeStatistics([])).toEqual({ + highestTradePrice: 0, + lowestTradePrice: 0, + averageTradePrice: 0, + totalTradedVolume: 0, + totalCounterVolume: 0, + numberOfTrades: 0, + }); + }); +}); + +describe('runner registration and README documentation', () => { + const expected = [ + [ + '120-transaction-lifecycle-monitor', + ['transactionHash', 'pollIntervalMs', 'timeoutMs', 'json'], + ], + [ + '121-account-history-pagination', + ['accountId', 'pageSize', 'maxRecords', 'operationType', 'json'], + ], + ['122-order-book-inspection', ['sellingAsset', 'buyingAsset', 'depth', 'json']], + [ + '123-trade-history-analysis', + ['sellingAsset', 'buyingAsset', 'limit', 'fromTime', 'toTime', 'json'], + ], + ] as const; + + test.each(expected)('registers %s in the interactive runner', (name, parameterNames) => { + const catalog = fs.readFileSync( + path.join(process.cwd(), 'src', 'runner', 'catalog.ts'), + 'utf8', + ); + + expect(catalog).toContain(`'${name}': {`); + expect(catalog).toContain(`name: '${name}'`); + expect(catalog).toContain(`loadExample('../examples/${name}')`); + + for (const parameterName of parameterNames) { + expect(catalog).toContain(`name: '${parameterName}'`); + } + }); + + test.each(expected)('documents %s in README', (name) => { + const readme = fs.readFileSync(path.join(process.cwd(), 'README.md'), 'utf8'); + expect(readme).toContain(`**\`${name}\`**`); + expect(readme).toContain(`npm run run-example ${name}`); + }); +});