From 6181ccacc455c2267407eec239eff169c3dfd8d9 Mon Sep 17 00:00:00 2001 From: samuel1-ona Date: Sun, 30 Aug 2026 21:08:45 +0100 Subject: [PATCH] feat: add asset, account, and Horizon diagnostics examples (#229-#232) Adds four runnable read-only examples, each with helper-level tests, runner registration, validation exclusions, and README catalog entries. - 169-asset-holder-distribution (ISSUE-169): pages every trustline for an issued asset, aggregates supply statistics (total, average, median, largest), ranks holders, and reports configurable top-N concentration. Distinguishes authorized from unauthorized balances, counts zero-balance trustlines separately, and reports mid-stream pagination failures rather than presenting a partial set as complete. - 170-account-balance-liability-analysis (ISSUE-170): analyses balances, trustline limits, and buying/selling liabilities, deriving available amounts and limit headroom. Ledger-reported and derived values are kept visually separate. Flags near-limit trustlines and unauthorized lines. - 171-account-activity-profile (ISSUE-171): combines account metadata, transaction, operation, and payment history into one descriptive profile with fee aggregation and operation-type grouping. Each Horizon section records its own status so partial failures are reported instead of surfacing as silent zeros. - 172-horizon-request-tracing (ISSUE-172): a reusable tracing wrapper recording per-request timing, HTTP status, attempt counts, and slow-request detection, with min/max/average/median latency aggregation. Diagnostics are sanitized (secret seeds, JWTs, token query params) before logging. Also repairs three files left syntactically broken by the merge in #253, which prevented the project from typechecking on main: - src/runner/catalog.ts: restored the missing brace on the 192 entry and un-swapped the params blocks of entries 67 and 192. - src/validation/validation.config.json: restored the object boundary between the 192 and 66 exclusions, which made the file invalid JSON. Verified: all four examples run against Horizon Testnet in both text and JSON modes; 103 new tests pass; no new typecheck or lint errors. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VtubmgtuY97zQVrDu3PSKb --- README.md | 4 + src/examples/169-asset-holder-distribution.ts | 551 ++++++++++++++++ .../170-account-balance-liability-analysis.ts | 455 +++++++++++++ src/examples/171-account-activity-profile.ts | 613 ++++++++++++++++++ src/examples/172-horizon-request-tracing.ts | 464 +++++++++++++ src/runner/catalog.ts | 123 +++- src/validation/validation.config.json | 18 + tests/account-activity-profile.test.ts | 308 +++++++++ ...account-balance-liability-analysis.test.ts | 227 +++++++ tests/asset-holder-distribution.test.ts | 342 ++++++++++ tests/horizon-request-tracing.test.ts | 282 ++++++++ 11 files changed, 3372 insertions(+), 15 deletions(-) create mode 100644 src/examples/169-asset-holder-distribution.ts create mode 100644 src/examples/170-account-balance-liability-analysis.ts create mode 100644 src/examples/171-account-activity-profile.ts create mode 100644 src/examples/172-horizon-request-tracing.ts create mode 100644 tests/account-activity-profile.test.ts create mode 100644 tests/account-balance-liability-analysis.test.ts create mode 100644 tests/asset-holder-distribution.test.ts create mode 100644 tests/horizon-request-tracing.test.ts diff --git a/README.md b/README.md index 65facca..1efbb14 100644 --- a/README.md +++ b/README.md @@ -236,6 +236,10 @@ The repository currently includes the following runnable examples: 81. **`158-resilient-horizon-streaming`**: Resilient Horizon streaming with cursor resume, duplicate/malformed event handling, exponential backoff reconnects, and stream statistics. 82. **`159-horizon-stream-filtering`**: Client-side AND/OR filtering pipeline for Horizon operation streams covering account, asset, operation type, success status, and amount ranges. 83. **`160-horizon-retry-rate-limit`**: Retry wrapper for transient Horizon failures and 429 rate limits with Retry-After parsing, exponential backoff, and request diagnostics. +84. **`169-asset-holder-distribution`**: Paging every trustline for an issued asset, aggregating supply statistics, ranking holders by balance, and reporting top-holder concentration. +85. **`170-account-balance-liability-analysis`**: Analysing account balances, trustline limits, buying and selling liabilities, and derived available amounts against the minimum reserve. +86. **`171-account-activity-profile`**: Building a read-only descriptive activity profile from account metadata, transaction, operation, and payment history with partial-failure reporting. +87. **`172-horizon-request-tracing`**: Tracing Horizon requests with per-request timing, retry counts, HTTP status, slow-request detection, latency aggregation, and sanitized diagnostics. ## Installation diff --git a/src/examples/169-asset-holder-distribution.ts b/src/examples/169-asset-holder-distribution.ts new file mode 100644 index 0000000..d03b22f --- /dev/null +++ b/src/examples/169-asset-holder-distribution.ts @@ -0,0 +1,551 @@ +/** + * 169-asset-holder-distribution: Stellar Asset Holder Distribution Analysis + * + * OVERVIEW + * -------- + * Asset explorers, dashboards, and analytics tools frequently need to answer + * one question about an issued Stellar asset: who holds it, and how evenly is + * it spread? Horizon exposes the raw material through `/accounts?asset=...`, + * which lists every account holding a trustline for the asset, but the + * aggregation and ranking has to happen client side. + * + * WHAT THIS EXAMPLE DOES + * ---------------------- + * 1. Validates an asset code and issuer account ID. + * 2. Pages through every trustline Horizon reports for that asset. + * 3. Extracts holder, balance, limit, authorization state, and liabilities. + * 4. Aggregates supply statistics (total, average, median, largest). + * 5. Ranks holders and reports top-N concentration percentages. + * + * The example is strictly read-only — it never builds or submits a transaction. + * + * HORIZON PAGINATION + * ------------------ + * `server.accounts().forAsset(asset)` returns at most 200 records per page. + * A complete holder set is assembled by following `page.next()` until a page + * comes back empty or a configured page/holder cap is reached. Because Horizon + * pages are cursor-based over live data, a page request can fail midway; this + * example records the failure and reports statistics over the holders it did + * retrieve rather than silently presenting a partial set as complete. + * + * BALANCES VS LIABILITIES + * ----------------------- + * A trustline balance is the ledger-reported amount held. Selling liabilities + * are amounts already committed to open SDEX offers, so a holder's balance is + * not necessarily freely transferable. Both are reported separately here; no + * netting is applied to the distribution totals, which are pure ledger sums. + * + * AUTHORIZATION + * ------------- + * Issuers using `AUTH_REQUIRED` must explicitly authorize each trustline. An + * unauthorized trustline can still report a non-zero balance (for example after + * a revocation), so authorized and unauthorized balances are tallied apart. + */ + +import { Asset, Horizon, StrKey } from '@stellar/stellar-sdk'; + +const DEFAULT_HORIZON_URL = 'https://horizon-testnet.stellar.org'; +const DEFAULT_ASSET_CODE = 'USDC'; +const DEFAULT_ASSET_ISSUER = 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5'; +const HORIZON_PAGE_LIMIT = 200; +const DEFAULT_MAX_HOLDERS = 400; +const DEFAULT_TOP_N = 10; +const DEFAULT_CONCENTRATION_TIERS = [1, 5, 10, 25, 50]; + +// ────────────────────────────────────────────────────────────────────────────── +// Types +// ────────────────────────────────────────────────────────────────────────────── + +export interface AssetHolder { + account: string; + balance: number; + limit: number | null; + authorized: boolean; + authorizedToMaintainLiabilities: boolean; + buyingLiabilities: number; + sellingLiabilities: number; +} + +export interface RankedHolder extends AssetHolder { + rank: number; + percentOfTotal: number; +} + +export interface ConcentrationTier { + topN: number; + balance: number; + percentOfTotal: number; +} + +export interface HolderStatistics { + holderCount: number; + nonZeroHolderCount: number; + zeroBalanceHolderCount: number; + totalBalance: number; + averageBalance: number; + medianBalance: number; + largestBalance: number; + authorizedHolderCount: number; + unauthorizedHolderCount: number; + authorizedBalance: number; + unauthorizedBalance: number; +} + +export interface HolderDistributionReport { + assetCode: string; + assetIssuer: string; + horizonUrl: string; + statistics: HolderStatistics; + topHolders: RankedHolder[]; + concentration: ConcentrationTier[]; + pagesFetched: number; + truncated: boolean; + paginationError: string | null; +} + +export interface RunParams { + assetCode?: string; + assetIssuer?: string; + maxHolders?: string | number; + topN?: string | number; + json?: boolean | string; +} + +// ────────────────────────────────────────────────────────────────────────────── +// Validation helpers +// ────────────────────────────────────────────────────────────────────────────── + +/** + * Checks an asset code against the Stellar alphanumeric-4 / alphanumeric-12 + * rules. `XLM` is rejected because the native asset has no issuer and no + * trustlines, so it has no holder distribution to analyse. + */ +export function isValidAssetCode(code: unknown): code is string { + if (typeof code !== 'string') return false; + return /^[A-Za-z0-9]{1,12}$/.test(code); +} + +/** Checks that a string is a valid ed25519 public key (`G…`). */ +export function isValidAccountId(accountId: unknown): accountId is string { + if (typeof accountId !== 'string') return false; + return StrKey.isValidEd25519PublicKey(accountId); +} + +/** + * Builds an `Asset` after validating both halves of its identifier, so that a + * bad input fails with an actionable message instead of an SDK assertion. + */ +export function buildAsset(code: string, issuer: string): Asset { + if (!isValidAssetCode(code)) { + throw new Error( + `Invalid asset code "${code}": expected 1-12 alphanumeric characters (e.g. USDC).`, + ); + } + if (!isValidAccountId(issuer)) { + throw new Error(`Invalid issuer account "${issuer}": expected a valid G… public key.`); + } + return new Asset(code, issuer); +} + +/** Parses a Horizon amount string into a number, treating anything unusable as 0. */ +export function parseAmount(value: unknown): number { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; +} + +/** Formats a numeric amount using Stellar's 7-decimal convention. */ +export function formatAmount(value: number): string { + return value.toFixed(7); +} + +// ────────────────────────────────────────────────────────────────────────────── +// Extraction +// ────────────────────────────────────────────────────────────────────────────── + +interface BalanceLine { + asset_type?: string; + asset_code?: string; + asset_issuer?: string; + balance?: string; + limit?: string; + is_authorized?: boolean; + is_authorized_to_maintain_liabilities?: boolean; + buying_liabilities?: string; + selling_liabilities?: string; +} + +interface AccountLike { + account_id?: string; + id?: string; + balances?: BalanceLine[]; +} + +/** + * Pulls the trustline for one asset out of an account record. + * + * Horizon returns an account's full balance list, so the matching line has to + * be selected by code *and* issuer — two different issuers can use the same + * code. Accounts without a matching line (which Horizon should not return, but + * which appear if the query is reused) yield `null` and are skipped. + */ +export function extractHolder( + account: AccountLike, + code: string, + issuer: string, +): AssetHolder | null { + const accountId = account.account_id ?? account.id; + if (!accountId) return null; + + const line = (account.balances ?? []).find( + (balance) => balance.asset_code === code && balance.asset_issuer === issuer, + ); + if (!line) return null; + + return { + account: accountId, + balance: parseAmount(line.balance), + limit: line.limit === undefined ? null : parseAmount(line.limit), + // Horizon omits the authorization flags entirely for issuers that do not + // use AUTH_REQUIRED; an absent flag means the trustline is usable. + authorized: line.is_authorized !== false, + authorizedToMaintainLiabilities: line.is_authorized_to_maintain_liabilities !== false, + buyingLiabilities: parseAmount(line.buying_liabilities), + sellingLiabilities: parseAmount(line.selling_liabilities), + }; +} + +/** Maps a page of Horizon account records to holders, dropping non-matches. */ +export function extractHolders( + accounts: AccountLike[], + code: string, + issuer: string, +): AssetHolder[] { + return accounts + .map((account) => extractHolder(account, code, issuer)) + .filter((holder): holder is AssetHolder => holder !== null); +} + +// ────────────────────────────────────────────────────────────────────────────── +// Statistics +// ────────────────────────────────────────────────────────────────────────────── + +/** + * Median of a numeric list. Even-length lists average the two middle values. + * The input is copied before sorting so callers keep their original ordering. + */ +export function median(values: number[]): number { + if (values.length === 0) return 0; + const sorted = [...values].sort((a, b) => a - b); + const middle = Math.floor(sorted.length / 2); + return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle]; +} + +/** Percentage of `total` represented by `part`, guarding against a zero supply. */ +export function percentOf(part: number, total: number): number { + if (total <= 0) return 0; + return (part / total) * 100; +} + +/** Sorts holders by balance, largest first, without mutating the input. */ +export function rankHolders(holders: AssetHolder[]): RankedHolder[] { + const total = holders.reduce((sum, holder) => sum + holder.balance, 0); + return [...holders] + .sort((a, b) => b.balance - a.balance) + .map((holder, index) => ({ + ...holder, + rank: index + 1, + percentOfTotal: percentOf(holder.balance, total), + })); +} + +/** Aggregate supply statistics over a holder set. */ +export function computeStatistics(holders: AssetHolder[]): HolderStatistics { + const balances = holders.map((holder) => holder.balance); + const totalBalance = balances.reduce((sum, balance) => sum + balance, 0); + const authorized = holders.filter((holder) => holder.authorized); + const unauthorized = holders.filter((holder) => !holder.authorized); + const sumOf = (subset: AssetHolder[]): number => + subset.reduce((sum, holder) => sum + holder.balance, 0); + + return { + holderCount: holders.length, + // Zero-balance trustlines are real ledger entries, so they count as holders + // but are surfaced separately — they otherwise drag the average downwards + // in a way that misrepresents actual circulation. + nonZeroHolderCount: holders.filter((holder) => holder.balance > 0).length, + zeroBalanceHolderCount: holders.filter((holder) => holder.balance === 0).length, + totalBalance, + averageBalance: holders.length === 0 ? 0 : totalBalance / holders.length, + medianBalance: median(balances), + largestBalance: holders.length === 0 ? 0 : Math.max(...balances), + authorizedHolderCount: authorized.length, + unauthorizedHolderCount: unauthorized.length, + authorizedBalance: sumOf(authorized), + unauthorizedBalance: sumOf(unauthorized), + }; +} + +/** + * Cumulative share of supply held by the largest N holders, for each requested + * tier. Tiers larger than the holder count are clamped so that, for example, + * "top 50" over 12 holders reports 12 holders at 100% rather than being dropped. + */ +export function computeConcentration( + ranked: RankedHolder[], + totalBalance: number, + tiers: number[] = DEFAULT_CONCENTRATION_TIERS, +): ConcentrationTier[] { + const seen = new Set(); + const result: ConcentrationTier[] = []; + + for (const tier of tiers) { + const size = Math.min(tier, ranked.length); + if (size <= 0 || seen.has(size)) continue; + seen.add(size); + + const balance = ranked.slice(0, size).reduce((sum, holder) => sum + holder.balance, 0); + result.push({ topN: size, balance, percentOfTotal: percentOf(balance, totalBalance) }); + } + + return result; +} + +/** Builds the full report from a holder set. */ +export function buildReport( + holders: AssetHolder[], + options: { + assetCode: string; + assetIssuer: string; + horizonUrl: string; + topN?: number; + pagesFetched?: number; + truncated?: boolean; + paginationError?: string | null; + }, +): HolderDistributionReport { + const statistics = computeStatistics(holders); + const ranked = rankHolders(holders); + + return { + assetCode: options.assetCode, + assetIssuer: options.assetIssuer, + horizonUrl: options.horizonUrl, + statistics, + topHolders: ranked.slice(0, options.topN ?? DEFAULT_TOP_N), + concentration: computeConcentration(ranked, statistics.totalBalance), + pagesFetched: options.pagesFetched ?? 0, + truncated: options.truncated ?? false, + paginationError: options.paginationError ?? null, + }; +} + +// ────────────────────────────────────────────────────────────────────────────── +// Horizon retrieval +// ────────────────────────────────────────────────────────────────────────────── + +interface HorizonPage { + records: T[]; + next: () => Promise>; +} + +export interface FetchResult { + holders: AssetHolder[]; + pagesFetched: number; + truncated: boolean; + paginationError: string | null; +} + +/** + * Pages through every trustline Horizon reports for the asset. + * + * Stops when a page comes back empty, when `maxHolders` is reached, or when a + * page request throws. A mid-stream failure is returned rather than raised so + * the caller can still report on the holders already collected, clearly marked + * as incomplete. + */ +export async function fetchAllHolders( + server: Horizon.Server, + asset: Asset, + maxHolders: number = DEFAULT_MAX_HOLDERS, +): Promise { + const holders: AssetHolder[] = []; + const code = asset.getCode(); + const issuer = asset.getIssuer(); + let pagesFetched = 0; + let truncated = false; + let paginationError: string | null = null; + + try { + let page = (await server + .accounts() + .forAsset(asset) + .limit(Math.min(HORIZON_PAGE_LIMIT, Math.max(1, maxHolders))) + .call()) as unknown as HorizonPage; + + while (page.records.length > 0) { + pagesFetched += 1; + holders.push(...extractHolders(page.records, code, issuer)); + + if (holders.length >= maxHolders) { + truncated = true; + break; + } + + page = await page.next(); + } + } catch (error: unknown) { + paginationError = error instanceof Error ? error.message : String(error); + } + + return { + holders: holders.slice(0, maxHolders), + pagesFetched, + truncated, + paginationError, + }; +} + +// ────────────────────────────────────────────────────────────────────────────── +// Display +// ────────────────────────────────────────────────────────────────────────────── + +function displayReport(report: HolderDistributionReport): void { + const { statistics: stats } = report; + + console.log('\n=== Asset Holder Distribution ==='); + console.log(` Asset: ${report.assetCode}`); + console.log(` Issuer: ${report.assetIssuer}`); + console.log(` Horizon: ${report.horizonUrl}`); + console.log(` Pages fetched: ${report.pagesFetched}`); + + if (stats.holderCount === 0) { + console.log('\n No trustlines found for this asset.'); + console.log(' An asset has no holders until at least one account creates a trustline.'); + return; + } + + console.log('\n── Supply Statistics (ledger-reported) ────────────────────'); + console.log(` Holders (trustlines): ${stats.holderCount}`); + console.log(` With non-zero balance: ${stats.nonZeroHolderCount}`); + console.log(` With zero balance: ${stats.zeroBalanceHolderCount}`); + console.log(` Total balance: ${formatAmount(stats.totalBalance)} ${report.assetCode}`); + console.log(` Largest holder balance: ${formatAmount(stats.largestBalance)}`); + + console.log('\n── Derived Statistics ─────────────────────────────────────'); + console.log(` Average balance: ${formatAmount(stats.averageBalance)}`); + console.log(` Median balance: ${formatAmount(stats.medianBalance)}`); + + console.log('\n── Authorization ──────────────────────────────────────────'); + console.log( + ` Authorized: ${stats.authorizedHolderCount} holders / ${formatAmount(stats.authorizedBalance)}`, + ); + console.log( + ` Unauthorized: ${stats.unauthorizedHolderCount} holders / ${formatAmount(stats.unauthorizedBalance)}`, + ); + + console.log( + `\n── Top ${report.topHolders.length} Holders ──────────────────────────────────────`, + ); + report.topHolders.forEach((holder) => { + console.log( + ` ${String(holder.rank).padStart(3)}. ${holder.account}` + + `\n balance=${formatAmount(holder.balance)}` + + ` (${holder.percentOfTotal.toFixed(2)}% of supply)` + + ` limit=${holder.limit === null ? 'none' : formatAmount(holder.limit)}` + + ` authorized=${holder.authorized}` + + `\n buyingLiabilities=${formatAmount(holder.buyingLiabilities)}` + + ` sellingLiabilities=${formatAmount(holder.sellingLiabilities)}`, + ); + }); + + console.log('\n── Concentration ──────────────────────────────────────────'); + report.concentration.forEach((tier) => { + console.log( + ` Top ${String(tier.topN).padStart(3)}: ${formatAmount(tier.balance)}` + + ` (${tier.percentOfTotal.toFixed(2)}% of supply)`, + ); + }); + + if (report.truncated) { + console.log( + '\n NOTE: the holder limit was reached — statistics cover the largest retrieved page set only.', + ); + } + if (report.paginationError) { + console.log(`\n WARNING: pagination stopped early: ${report.paginationError}`); + console.log(' Statistics above cover only the holders retrieved before the failure.'); + } +} + +// ────────────────────────────────────────────────────────────────────────────── +// Entry point +// ────────────────────────────────────────────────────────────────────────────── + +function wantsJson(params: RunParams): boolean { + return ( + params.json === true || + params.json === 'true' || + process.env.OUTPUT_FORMAT === 'json' || + process.argv.includes('--json') + ); +} + +function parsePositiveInt(value: unknown, fallback: number): number { + const parsed = Number(value); + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; +} + +/** + * Runs the asset holder distribution example. + */ +export async function run(params: RunParams = {}): Promise { + const horizonUrl = process.env.HORIZON_URL ?? DEFAULT_HORIZON_URL; + const outputJson = wantsJson(params); + + const assetCode = + (typeof params.assetCode === 'string' && params.assetCode.trim()) || + process.env.ASSET_CODE?.trim() || + DEFAULT_ASSET_CODE; + const assetIssuer = + (typeof params.assetIssuer === 'string' && params.assetIssuer.trim()) || + process.env.ASSET_ISSUER?.trim() || + DEFAULT_ASSET_ISSUER; + const maxHolders = parsePositiveInt( + params.maxHolders ?? process.env.MAX_HOLDERS, + DEFAULT_MAX_HOLDERS, + ); + const topN = parsePositiveInt(params.topN ?? process.env.TOP_HOLDERS, DEFAULT_TOP_N); + + console.log('Starting Asset Holder Distribution Example...'); + console.log(`Using Horizon: ${horizonUrl}`); + + const asset = buildAsset(assetCode, assetIssuer); + console.log(`Analysing holders of ${asset.getCode()} issued by ${asset.getIssuer()}`); + console.log(`Holder cap: ${maxHolders} · Top holders displayed: ${topN}`); + + const server = new Horizon.Server(horizonUrl); + const fetched = await fetchAllHolders(server, asset, maxHolders); + + const report = buildReport(fetched.holders, { + assetCode: asset.getCode(), + assetIssuer: asset.getIssuer(), + horizonUrl, + topN, + pagesFetched: fetched.pagesFetched, + truncated: fetched.truncated, + paginationError: fetched.paginationError, + }); + + if (outputJson) { + console.log(JSON.stringify(report, null, 2)); + return; + } + + displayReport(report); + + console.log('\n── Notes ──────────────────────────────────────────────────'); + console.log(' • This example is read-only; no ledger state was modified.'); + console.log(' • Totals are sums of trustline balances, not the issuer supply figure.'); + console.log(' • Balances committed to open offers appear as selling liabilities.'); + console.log('\nAsset holder distribution analysis completed.'); +} diff --git a/src/examples/170-account-balance-liability-analysis.ts b/src/examples/170-account-balance-liability-analysis.ts new file mode 100644 index 0000000..cc26351 --- /dev/null +++ b/src/examples/170-account-balance-liability-analysis.ts @@ -0,0 +1,455 @@ +/** + * 170-account-balance-liability-analysis: Account Balance and Liability Analysis + * + * OVERVIEW + * -------- + * The balance Horizon reports for an account is the amount the ledger holds, + * not the amount the account can immediately spend. Two things reduce it: + * + * - Selling liabilities: amounts already committed to open SDEX offers. + * - The minimum reserve (native XLM only): base reserve plus one reserve per + * subentry, which can never be spent while those subentries exist. + * + * Buying liabilities work in the other direction — they are amounts the account + * has committed to *receive*, and they count against a trustline's limit rather + * than against its balance. + * + * AVAILABLE AMOUNT + * ---------------- + * Issued asset: available = balance - sellingLiabilities + * Native XLM: available = balance - sellingLiabilities - minimumReserve + * Headroom: headroom = limit - balance - buyingLiabilities + * + * `minimumReserve = (2 + subentryCount - numSponsored + numSponsoring) × 0.5 XLM` + * + * All three are *derived* values computed from ledger-reported fields. The + * report keeps them visually separate from what Horizon actually returned, so a + * reader is never misled into treating a computed figure as authoritative + * ledger state. + * + * TRUSTLINE LIMITS + * ---------------- + * A trustline caps how much of an asset an account may hold. Once + * `balance + buyingLiabilities` approaches `limit`, further incoming payments + * and buy offers start failing with `op_line_full`, so trustlines close to + * their limit are flagged. + * + * AUTHORIZATION + * ------------- + * An issuer using `AUTH_REQUIRED` must authorize each trustline. An + * unauthorized trustline may still report a balance, but that balance cannot be + * transferred; a trustline authorized only "to maintain liabilities" can settle + * existing offers but cannot accept new payments. + * + * This example is read-only and submits nothing to the network. + */ + +import { Horizon, StrKey } from '@stellar/stellar-sdk'; + +const DEFAULT_HORIZON_URL = 'https://horizon-testnet.stellar.org'; +const FALLBACK_ACCOUNT_ID = 'GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN7'; +const BASE_RESERVE_XLM = 0.5; +const BASE_ENTRY_COUNT = 2; +/** Fraction of a trustline limit above which the line is reported as "near limit". */ +const NEAR_LIMIT_THRESHOLD = 0.9; +/** Fraction of a balance above which liabilities are reported as "significant". */ +const SIGNIFICANT_LIABILITY_THRESHOLD = 0.5; + +// ────────────────────────────────────────────────────────────────────────────── +// Types +// ────────────────────────────────────────────────────────────────────────────── + +export interface BalanceLine { + asset_type?: string; + asset_code?: string; + asset_issuer?: string; + balance?: string; + limit?: string; + buying_liabilities?: string; + selling_liabilities?: string; + is_authorized?: boolean; + is_authorized_to_maintain_liabilities?: boolean; +} + +export interface AccountLike { + account_id?: string; + id?: string; + sequence?: string; + subentry_count?: number; + num_sponsored?: number; + num_sponsoring?: number; + balances?: BalanceLine[]; +} + +/** One analysed balance line. `reported` fields come from Horizon verbatim. */ +export interface AssetPosition { + assetType: string; + assetCode: string; + assetIssuer: string | null; + isNative: boolean; + reported: { + balance: number; + limit: number | null; + buyingLiabilities: number; + sellingLiabilities: number; + authorized: boolean; + authorizedToMaintainLiabilities: boolean; + }; + derived: { + totalLiabilities: number; + availableAmount: number; + reserveDeduction: number; + limitUtilization: number | null; + limitHeadroom: number | null; + nearLimit: boolean; + significantLiabilities: boolean; + }; +} + +export interface AccountLiabilityReport { + accountId: string; + horizonUrl: string; + sequence: string | null; + reported: { + balanceCount: number; + subentryCount: number; + numSponsored: number; + numSponsoring: number; + }; + derived: { + minimumReserveXlm: number; + nativeBalance: number; + nativeAvailable: number; + nativeBuyingLiabilities: number; + nativeSellingLiabilities: number; + issuedAssetCount: number; + unauthorizedTrustlineCount: number; + nearLimitTrustlineCount: number; + }; + native: AssetPosition | null; + issuedAssets: AssetPosition[]; + warnings: string[]; +} + +export interface RunParams { + accountId?: string; + json?: boolean | string; +} + +// ────────────────────────────────────────────────────────────────────────────── +// Helpers +// ────────────────────────────────────────────────────────────────────────────── + +/** Checks that a string is a valid ed25519 public key (`G…`). */ +export function isValidAccountId(accountId: unknown): accountId is string { + if (typeof accountId !== 'string') return false; + return StrKey.isValidEd25519PublicKey(accountId); +} + +/** Parses a Horizon amount string into a number, treating anything unusable as 0. */ +export function parseAmount(value: unknown): number { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; +} + +/** Formats a numeric amount using Stellar's 7-decimal convention. */ +export function formatAmount(value: number): string { + return value.toFixed(7); +} + +/** + * Minimum XLM an account must retain. + * + * Sponsored subentries are paid for by a sponsor and so are subtracted, while + * subentries this account sponsors for others are added to its own requirement. + */ +export function calculateMinimumReserve( + subentryCount: number, + numSponsored = 0, + numSponsoring = 0, + baseReserve = BASE_RESERVE_XLM, +): number { + const entries = BASE_ENTRY_COUNT + subentryCount - numSponsored + numSponsoring; + return Math.max(0, entries) * baseReserve; +} + +/** Human-readable label for a balance line. */ +export function describeAsset(line: BalanceLine): string { + if (line.asset_type === 'native') return 'XLM (native)'; + if (line.asset_type === 'liquidity_pool_shares') return 'Liquidity pool shares'; + return line.asset_code ?? 'unknown'; +} + +// ────────────────────────────────────────────────────────────────────────────── +// Analysis +// ────────────────────────────────────────────────────────────────────────────── + +/** + * Analyses one balance line into reported and derived halves. + * + * `reserveDeduction` is non-zero only for the native balance: issued assets + * carry no reserve of their own. Available amounts are floored at zero because + * a negative "available" is not meaningful — it just means the whole balance is + * spoken for. + */ +export function analysePosition(line: BalanceLine, reserveDeduction = 0): AssetPosition { + const isNative = line.asset_type === 'native'; + const balance = parseAmount(line.balance); + const buyingLiabilities = parseAmount(line.buying_liabilities); + const sellingLiabilities = parseAmount(line.selling_liabilities); + const limit = line.limit === undefined ? null : parseAmount(line.limit); + + const availableAmount = Math.max(0, balance - sellingLiabilities - reserveDeduction); + // Headroom counts buying liabilities because incoming amounts already + // committed to open buy offers will land on this trustline. + const limitHeadroom = limit === null ? null : Math.max(0, limit - balance - buyingLiabilities); + const limitUtilization = + limit === null || limit <= 0 ? null : ((balance + buyingLiabilities) / limit) * 100; + + return { + assetType: line.asset_type ?? 'unknown', + assetCode: describeAsset(line), + assetIssuer: line.asset_issuer ?? null, + isNative, + reported: { + balance, + limit, + buyingLiabilities, + sellingLiabilities, + // Horizon omits these flags when the issuer does not use AUTH_REQUIRED, + // in which case the trustline is authorized by default. + authorized: line.is_authorized !== false, + authorizedToMaintainLiabilities: line.is_authorized_to_maintain_liabilities !== false, + }, + derived: { + totalLiabilities: buyingLiabilities + sellingLiabilities, + availableAmount, + reserveDeduction, + limitUtilization, + limitHeadroom, + nearLimit: limitUtilization !== null && limitUtilization >= NEAR_LIMIT_THRESHOLD * 100, + significantLiabilities: + balance > 0 && + buyingLiabilities + sellingLiabilities >= balance * SIGNIFICANT_LIABILITY_THRESHOLD, + }, + }; +} + +/** Builds the complete balance-and-liability report for an account record. */ +export function analyseAccount( + account: AccountLike, + horizonUrl: string, + baseReserve = BASE_RESERVE_XLM, +): AccountLiabilityReport { + const balances = account.balances ?? []; + const subentryCount = account.subentry_count ?? 0; + const numSponsored = account.num_sponsored ?? 0; + const numSponsoring = account.num_sponsoring ?? 0; + const minimumReserveXlm = calculateMinimumReserve( + subentryCount, + numSponsored, + numSponsoring, + baseReserve, + ); + + const nativeLine = balances.find((line) => line.asset_type === 'native'); + const native = nativeLine ? analysePosition(nativeLine, minimumReserveXlm) : null; + const issuedAssets = balances + .filter((line) => line.asset_type !== 'native') + .map((line) => analysePosition(line)); + + const warnings: string[] = []; + if (!native) { + warnings.push('Account has no native XLM balance line — unusual for a funded account.'); + } + if (issuedAssets.length === 0) { + warnings.push('Account holds no issued assets; only the native balance is analysed.'); + } + if (native?.derived.availableAmount === 0 && native.reported.balance > 0) { + warnings.push( + 'Entire native balance is locked by the minimum reserve and selling liabilities.', + ); + } + issuedAssets + .filter((position) => !position.reported.authorized) + .forEach((position) => + warnings.push(`Trustline for ${position.assetCode} is not authorized by its issuer.`), + ); + issuedAssets + .filter((position) => position.derived.nearLimit) + .forEach((position) => + warnings.push( + `Trustline for ${position.assetCode} is at ` + + `${position.derived.limitUtilization?.toFixed(2)}% of its limit — ` + + 'further inbound payments may fail with op_line_full.', + ), + ); + + return { + accountId: account.account_id ?? account.id ?? 'unknown', + horizonUrl, + sequence: account.sequence ?? null, + reported: { + balanceCount: balances.length, + subentryCount, + numSponsored, + numSponsoring, + }, + derived: { + minimumReserveXlm, + nativeBalance: native?.reported.balance ?? 0, + nativeAvailable: native?.derived.availableAmount ?? 0, + nativeBuyingLiabilities: native?.reported.buyingLiabilities ?? 0, + nativeSellingLiabilities: native?.reported.sellingLiabilities ?? 0, + issuedAssetCount: issuedAssets.length, + unauthorizedTrustlineCount: issuedAssets.filter((p) => !p.reported.authorized).length, + nearLimitTrustlineCount: issuedAssets.filter((p) => p.derived.nearLimit).length, + }, + native, + issuedAssets, + warnings, + }; +} + +// ────────────────────────────────────────────────────────────────────────────── +// Display +// ────────────────────────────────────────────────────────────────────────────── + +function displayPosition(position: AssetPosition): void { + const issuer = position.assetIssuer ? `${position.assetIssuer.slice(0, 8)}…` : 'n/a'; + console.log(`\n ${position.assetCode} (issuer: ${issuer})`); + console.log(' Ledger-reported:'); + console.log(` balance = ${formatAmount(position.reported.balance)}`); + console.log( + ` limit = ${position.reported.limit === null ? 'n/a (native)' : formatAmount(position.reported.limit)}`, + ); + console.log(` buying liabilities = ${formatAmount(position.reported.buyingLiabilities)}`); + console.log(` selling liabilities = ${formatAmount(position.reported.sellingLiabilities)}`); + console.log(` authorized = ${position.reported.authorized}`); + console.log(` auth. to maintain = ${position.reported.authorizedToMaintainLiabilities}`); + console.log(' Derived:'); + console.log(` available amount = ${formatAmount(position.derived.availableAmount)}`); + if (position.derived.reserveDeduction > 0) { + console.log( + ` reserve deduction = ${formatAmount(position.derived.reserveDeduction)} (native only)`, + ); + } + if (position.derived.limitUtilization !== null) { + console.log(` limit utilization = ${position.derived.limitUtilization.toFixed(2)}%`); + console.log(` headroom = ${formatAmount(position.derived.limitHeadroom ?? 0)}`); + } + if (position.derived.nearLimit) { + console.log(' ⚠ trustline is near its limit'); + } + if (position.derived.significantLiabilities) { + console.log(' ⚠ liabilities are significant relative to the balance'); + } +} + +function displayReport(report: AccountLiabilityReport): void { + console.log('\n=== Account Balance and Liability Analysis ==='); + console.log(` Account: ${report.accountId}`); + console.log(` Horizon: ${report.horizonUrl}`); + console.log(` Sequence: ${report.sequence ?? 'unknown'}`); + + console.log('\n── Account Summary ────────────────────────────────────────'); + console.log(` Balance lines: ${report.reported.balanceCount}`); + console.log(` Issued assets: ${report.derived.issuedAssetCount}`); + console.log(` Subentries: ${report.reported.subentryCount}`); + console.log(` Sponsored entries: ${report.reported.numSponsored}`); + console.log(` Sponsoring entries: ${report.reported.numSponsoring}`); + console.log( + ` Minimum reserve: ${formatAmount(report.derived.minimumReserveXlm)} XLM (derived)`, + ); + console.log( + ` Native available: ${formatAmount(report.derived.nativeAvailable)} XLM (derived)`, + ); + console.log(` Unauthorized lines: ${report.derived.unauthorizedTrustlineCount}`); + console.log(` Near-limit lines: ${report.derived.nearLimitTrustlineCount}`); + + console.log('\n── Native XLM ─────────────────────────────────────────────'); + if (report.native) { + displayPosition(report.native); + } else { + console.log(' No native balance line found.'); + } + + console.log('\n── Issued Assets ──────────────────────────────────────────'); + if (report.issuedAssets.length === 0) { + console.log(' This account holds no issued assets.'); + } else { + report.issuedAssets.forEach(displayPosition); + } + + if (report.warnings.length > 0) { + console.log('\n── Notes and Warnings ─────────────────────────────────────'); + report.warnings.forEach((warning) => console.log(` • ${warning}`)); + } + + console.log('\n── How to Read This ───────────────────────────────────────'); + console.log(' • "Ledger-reported" values come straight from Horizon.'); + console.log(' • "Derived" values are computed by this example, not by the ledger.'); + console.log(' • Selling liabilities are already committed to open offers.'); + console.log(' • Buying liabilities count against a trustline limit, not the balance.'); + console.log(' • Only the native balance carries the minimum reserve deduction.'); +} + +// ────────────────────────────────────────────────────────────────────────────── +// Entry point +// ────────────────────────────────────────────────────────────────────────────── + +function wantsJson(params: RunParams): boolean { + return ( + params.json === true || + params.json === 'true' || + process.env.OUTPUT_FORMAT === 'json' || + process.argv.includes('--json') + ); +} + +/** + * Runs the account balance and liability analysis example. + */ +export async function run(params: RunParams = {}): Promise { + const horizonUrl = process.env.HORIZON_URL ?? DEFAULT_HORIZON_URL; + const outputJson = wantsJson(params); + + const accountId = + (typeof params.accountId === 'string' && params.accountId.trim()) || + process.env.ACCOUNT_ID?.trim() || + FALLBACK_ACCOUNT_ID; + + console.log('Starting Account Balance and Liability Analysis Example...'); + console.log(`Using Horizon: ${horizonUrl}`); + + if (!isValidAccountId(accountId)) { + throw new Error( + `Invalid account ID "${accountId}": expected a 56-character G… ed25519 public key.`, + ); + } + + console.log(`Analysing account: ${accountId}`); + + const server = new Horizon.Server(horizonUrl); + + let account: AccountLike; + try { + account = (await server.loadAccount(accountId)) as unknown as AccountLike; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + throw new Error( + `Could not load account ${accountId} from ${horizonUrl}: ${message}. ` + + 'Verify the account exists on this network and has been funded.', + ); + } + + const report = analyseAccount(account, horizonUrl); + + if (outputJson) { + console.log(JSON.stringify(report, null, 2)); + return; + } + + displayReport(report); + console.log('\nAccount balance and liability analysis completed.'); +} diff --git a/src/examples/171-account-activity-profile.ts b/src/examples/171-account-activity-profile.ts new file mode 100644 index 0000000..7d3366b --- /dev/null +++ b/src/examples/171-account-activity-profile.ts @@ -0,0 +1,613 @@ +/** + * 171-account-activity-profile: Stellar Account Activity Profile + * + * OVERVIEW + * -------- + * Wallets, explorers, and monitoring systems usually need a single summary of + * what an account has been doing rather than four separate Horizon responses. + * This example collects account metadata, transaction history, operation + * history, and payment history, then reduces them to one structured profile. + * + * SCOPE — DESCRIPTIVE ONLY + * ------------------------ + * This profile is purely descriptive. It reports what the ledger records and + * nothing more. It deliberately does NOT score, rank, or classify an account as + * trustworthy, risky, or fraudulent, and it makes no attempt to associate an + * account with a real-world identity. On-chain activity does not support those + * conclusions, and presenting derived statistics as if it did would be + * misleading. + * + * PARTIAL FAILURES + * ---------------- + * The profile is assembled from four independent Horizon requests. Any one of + * them can fail on its own (rate limiting, a retention window, a transient + * error). Rather than aborting or silently reporting zeros — which would look + * identical to a genuinely inactive account — each section records its own + * status, and the profile lists which sections are incomplete. + * + * OBSERVED VS DERIVED + * ------------------- + * "Observed" counts describe only the records actually retrieved, which are + * bounded by the configured page limit and by Horizon's own history retention. + * They are not lifetime totals for the account. Every derived figure — averages, + * rates, groupings — is computed from that same bounded sample and is labelled + * as derived throughout. + * + * This example is read-only and submits nothing to the network. + */ + +import { Horizon, StrKey } from '@stellar/stellar-sdk'; + +const DEFAULT_HORIZON_URL = 'https://horizon-testnet.stellar.org'; +const FALLBACK_ACCOUNT_ID = 'GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN7'; +const DEFAULT_RECORD_LIMIT = 50; +const MAX_RECORD_LIMIT = 200; +const DEFAULT_RECENT_ACTIVITY_COUNT = 5; +/** Stroops per lumen — Horizon reports transaction fees in stroops. */ +const STROOPS_PER_XLM = 10_000_000; + +// ────────────────────────────────────────────────────────────────────────────── +// Types +// ────────────────────────────────────────────────────────────────────────────── + +export interface TransactionRecord { + id?: string; + hash?: string; + ledger?: number; + ledger_attr?: number; + created_at?: string; + successful?: boolean; + fee_charged?: string | number; + operation_count?: number; + source_account?: string; +} + +export interface OperationRecord { + id?: string; + type?: string; + created_at?: string; + source_account?: string; + transaction_successful?: boolean; +} + +export interface PaymentRecord { + id?: string; + type?: string; + created_at?: string; + from?: string; + to?: string; + source_account?: string; + amount?: string; + asset_type?: string; + asset_code?: string; +} + +export interface AccountLike { + account_id?: string; + id?: string; + sequence?: string; + subentry_count?: number; + last_modified_ledger?: number; + balances?: Array<{ + asset_type?: string; + asset_code?: string; + balance?: string; + }>; +} + +export interface SectionStatus { + ok: boolean; + error: string | null; +} + +export interface TransactionStatistics { + total: number; + successful: number; + failed: number; + successRate: number; + totalFeeStroops: number; + totalFeeXlm: number; + averageFeeStroops: number; + operationCountFromTransactions: number; + firstObservedAt: string | null; + lastObservedAt: string | null; + activeLedgers: number; +} + +export interface OperationStatistics { + total: number; + byType: Record; + mostFrequentTypes: Array<{ type: string; count: number }>; +} + +export interface PaymentStatistics { + total: number; + incoming: number; + outgoing: number; + selfPayments: number; + byAsset: Record; +} + +export interface BalanceSummary { + balanceCount: number; + nativeBalance: string | null; + trustlineCount: number; + assets: string[]; +} + +export interface ActivityProfile { + accountId: string; + horizonUrl: string; + recordLimit: number; + observedAt: string; + account: { + sequence: string | null; + subentryCount: number; + lastModifiedLedger: number | null; + }; + balances: BalanceSummary; + transactions: TransactionStatistics; + operations: OperationStatistics; + payments: PaymentStatistics; + recentActivity: Array<{ at: string; type: string; detail: string }>; + derived: { + operationsPerTransaction: number; + observedWindowHours: number | null; + transactionsPerDay: number | null; + }; + sections: { + account: SectionStatus; + transactions: SectionStatus; + operations: SectionStatus; + payments: SectionStatus; + }; + incompleteSections: string[]; +} + +export interface RunParams { + accountId?: string; + limit?: string | number; + json?: boolean | string; +} + +// ────────────────────────────────────────────────────────────────────────────── +// Helpers +// ────────────────────────────────────────────────────────────────────────────── + +/** Checks that a string is a valid ed25519 public key (`G…`). */ +export function isValidAccountId(accountId: unknown): accountId is string { + if (typeof accountId !== 'string') return false; + return StrKey.isValidEd25519PublicKey(accountId); +} + +/** Clamps a requested page size into Horizon's accepted 1–200 range. */ +export function normalizeLimit(value: unknown, fallback = DEFAULT_RECORD_LIMIT): number { + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed <= 0) return fallback; + return Math.min(parsed, MAX_RECORD_LIMIT); +} + +function okStatus(): SectionStatus { + return { ok: true, error: null }; +} + +function errorStatus(error: unknown): SectionStatus { + return { ok: false, error: error instanceof Error ? error.message : String(error) }; +} + +// ────────────────────────────────────────────────────────────────────────────── +// Statistics +// ────────────────────────────────────────────────────────────────────────────── + +/** + * Reduces retrieved transactions to counts, fee totals, and an observed window. + * + * Fees are reported by Horizon in stroops as `fee_charged` — the amount actually + * taken, which for a failed transaction is still non-zero. Both the stroop total + * and its XLM equivalent are reported so callers do not have to remember the + * conversion. + */ +export function summarizeTransactions(records: TransactionRecord[]): TransactionStatistics { + const successful = records.filter((record) => record.successful === true).length; + // Horizon marks failed transactions explicitly; anything not marked successful + // and included in the account's history is counted as failed. + const failed = records.length - successful; + const totalFeeStroops = records.reduce((sum, record) => { + const fee = Number(record.fee_charged); + return sum + (Number.isFinite(fee) ? fee : 0); + }, 0); + + const timestamps = records + .map((record) => record.created_at) + .filter((value): value is string => typeof value === 'string') + .sort(); + + const ledgers = new Set( + records + .map((record) => record.ledger ?? record.ledger_attr) + .filter((value): value is number => typeof value === 'number'), + ); + + return { + total: records.length, + successful, + failed, + successRate: records.length === 0 ? 0 : (successful / records.length) * 100, + totalFeeStroops, + totalFeeXlm: totalFeeStroops / STROOPS_PER_XLM, + averageFeeStroops: records.length === 0 ? 0 : totalFeeStroops / records.length, + operationCountFromTransactions: records.reduce( + (sum, record) => sum + (record.operation_count ?? 0), + 0, + ), + firstObservedAt: timestamps[0] ?? null, + lastObservedAt: timestamps[timestamps.length - 1] ?? null, + activeLedgers: ledgers.size, + }; +} + +/** Groups operations by type and identifies the most frequent ones. */ +export function summarizeOperations(records: OperationRecord[]): OperationStatistics { + const byType: Record = {}; + for (const record of records) { + const type = record.type ?? 'unknown'; + byType[type] = (byType[type] ?? 0) + 1; + } + + const mostFrequentTypes = Object.entries(byType) + .map(([type, count]) => ({ type, count })) + .sort((a, b) => b.count - a.count || a.type.localeCompare(b.type)) + .slice(0, 5); + + return { total: records.length, byType, mostFrequentTypes }; +} + +/** + * Splits payments into incoming and outgoing relative to `accountId`. + * + * `create_account` operations use `funder`/`account` rather than `from`/`to`, + * so the source account is used as the sender fallback. A payment where the + * account is both sender and receiver is counted once in each direction and + * also tracked separately, so the two never silently double-count. + */ +export function summarizePayments(records: PaymentRecord[], accountId: string): PaymentStatistics { + let incoming = 0; + let outgoing = 0; + let selfPayments = 0; + const byAsset: Record = {}; + + for (const record of records) { + const from = record.from ?? record.source_account; + const to = record.to; + const isIncoming = to === accountId; + const isOutgoing = from === accountId; + + if (isIncoming) incoming += 1; + if (isOutgoing) outgoing += 1; + if (isIncoming && isOutgoing) selfPayments += 1; + + const asset = record.asset_type === 'native' ? 'XLM' : (record.asset_code ?? 'unknown'); + byAsset[asset] = (byAsset[asset] ?? 0) + 1; + } + + return { total: records.length, incoming, outgoing, selfPayments, byAsset }; +} + +/** Summarizes the account's current balances and trustlines. */ +export function summarizeBalances(account: AccountLike): BalanceSummary { + const balances = account.balances ?? []; + const native = balances.find((balance) => balance.asset_type === 'native'); + const trustlines = balances.filter((balance) => balance.asset_type !== 'native'); + + return { + balanceCount: balances.length, + nativeBalance: native?.balance ?? null, + trustlineCount: trustlines.length, + assets: trustlines.map((balance) => balance.asset_code ?? 'unknown'), + }; +} + +/** Most recent operations, newest first, rendered as short activity lines. */ +export function buildRecentActivity( + operations: OperationRecord[], + count = DEFAULT_RECENT_ACTIVITY_COUNT, +): Array<{ at: string; type: string; detail: string }> { + return [...operations] + .sort((a, b) => (b.created_at ?? '').localeCompare(a.created_at ?? '')) + .slice(0, count) + .map((operation) => ({ + at: operation.created_at ?? 'unknown', + type: operation.type ?? 'unknown', + detail: + `source=${operation.source_account ?? 'unknown'}` + + (operation.transaction_successful === false ? ' (transaction failed)' : ''), + })); +} + +/** + * Elapsed hours between the first and last observed transaction. + * + * Returns `null` when fewer than two timestamps are available, because a single + * record gives a zero-length window and any rate derived from it would be + * meaningless rather than merely imprecise. + */ +export function observedWindowHours(stats: TransactionStatistics): number | null { + if (!stats.firstObservedAt || !stats.lastObservedAt) return null; + const first = Date.parse(stats.firstObservedAt); + const last = Date.parse(stats.lastObservedAt); + if (!Number.isFinite(first) || !Number.isFinite(last) || last <= first) return null; + return (last - first) / (1000 * 60 * 60); +} + +/** Builds the assembled profile from each section's retrieved records. */ +export function buildProfile(input: { + accountId: string; + horizonUrl: string; + recordLimit: number; + account: AccountLike | null; + transactions: TransactionRecord[]; + operations: OperationRecord[]; + payments: PaymentRecord[]; + sections: ActivityProfile['sections']; +}): ActivityProfile { + const transactionStats = summarizeTransactions(input.transactions); + const operationStats = summarizeOperations(input.operations); + const paymentStats = summarizePayments(input.payments, input.accountId); + const windowHours = observedWindowHours(transactionStats); + + const incompleteSections = Object.entries(input.sections) + .filter(([, status]) => !status.ok) + .map(([name]) => name); + + return { + accountId: input.accountId, + horizonUrl: input.horizonUrl, + recordLimit: input.recordLimit, + observedAt: new Date().toISOString(), + account: { + sequence: input.account?.sequence ?? null, + subentryCount: input.account?.subentry_count ?? 0, + lastModifiedLedger: input.account?.last_modified_ledger ?? null, + }, + balances: input.account + ? summarizeBalances(input.account) + : { balanceCount: 0, nativeBalance: null, trustlineCount: 0, assets: [] }, + transactions: transactionStats, + operations: operationStats, + payments: paymentStats, + recentActivity: buildRecentActivity(input.operations), + derived: { + operationsPerTransaction: + transactionStats.total === 0 ? 0 : operationStats.total / transactionStats.total, + observedWindowHours: windowHours, + transactionsPerDay: + windowHours === null || windowHours === 0 + ? null + : transactionStats.total / (windowHours / 24), + }, + sections: input.sections, + incompleteSections, + }; +} + +// ────────────────────────────────────────────────────────────────────────────── +// Display +// ────────────────────────────────────────────────────────────────────────────── + +function displayProfile(profile: ActivityProfile): void { + console.log('\n=== Account Activity Profile ==='); + console.log(` Account: ${profile.accountId}`); + console.log(` Horizon: ${profile.horizonUrl}`); + console.log(` Record limit: ${profile.recordLimit} per resource`); + console.log(` Observed at: ${profile.observedAt}`); + + console.log('\n── Account (observed) ─────────────────────────────────────'); + console.log(` Sequence: ${profile.account.sequence ?? 'unknown'}`); + console.log(` Subentries: ${profile.account.subentryCount}`); + console.log(` Last modified ledger: ${profile.account.lastModifiedLedger ?? 'unknown'}`); + console.log(` Balance lines: ${profile.balances.balanceCount}`); + console.log(` Native XLM balance: ${profile.balances.nativeBalance ?? 'none'}`); + console.log(` Trustlines: ${profile.balances.trustlineCount}`); + if (profile.balances.assets.length > 0) { + console.log(` Assets held: ${profile.balances.assets.join(', ')}`); + } + + const tx = profile.transactions; + console.log('\n── Transactions (observed) ────────────────────────────────'); + console.log(` Retrieved: ${tx.total}`); + console.log(` Successful: ${tx.successful}`); + console.log(` Failed: ${tx.failed}`); + console.log(` Success rate: ${tx.successRate.toFixed(2)}% (derived)`); + console.log(` Active ledgers: ${tx.activeLedgers}`); + console.log(` First observed: ${tx.firstObservedAt ?? 'n/a'}`); + console.log(` Last observed: ${tx.lastObservedAt ?? 'n/a'}`); + + console.log('\n── Fees (observed) ────────────────────────────────────────'); + console.log(` Total fees charged: ${tx.totalFeeStroops} stroops`); + console.log(` Total fees charged: ${tx.totalFeeXlm.toFixed(7)} XLM (derived)`); + console.log(` Average fee: ${tx.averageFeeStroops.toFixed(2)} stroops (derived)`); + + console.log('\n── Operations (observed) ──────────────────────────────────'); + console.log(` Retrieved: ${profile.operations.total}`); + console.log( + ` Operations per transaction: ${profile.derived.operationsPerTransaction.toFixed(2)} (derived)`, + ); + if (profile.operations.mostFrequentTypes.length === 0) { + console.log(' No operations retrieved.'); + } else { + console.log(' Most frequent types:'); + profile.operations.mostFrequentTypes.forEach((entry) => + console.log(` ${entry.type}: ${entry.count}`), + ); + } + + console.log('\n── Payments (observed) ────────────────────────────────────'); + console.log(` Retrieved: ${profile.payments.total}`); + console.log(` Incoming: ${profile.payments.incoming}`); + console.log(` Outgoing: ${profile.payments.outgoing}`); + if (profile.payments.selfPayments > 0) { + console.log(` Self-payments (counted in both directions): ${profile.payments.selfPayments}`); + } + const assetEntries = Object.entries(profile.payments.byAsset); + if (assetEntries.length > 0) { + console.log(' By asset:'); + assetEntries.forEach(([asset, count]) => console.log(` ${asset}: ${count}`)); + } + + console.log('\n── Activity Frequency (derived) ───────────────────────────'); + console.log( + ` Observed window: ${profile.derived.observedWindowHours === null ? 'n/a (need 2+ dated transactions)' : `${profile.derived.observedWindowHours.toFixed(2)} hours`}`, + ); + console.log( + ` Transactions/day: ${profile.derived.transactionsPerDay === null ? 'n/a' : profile.derived.transactionsPerDay.toFixed(2)}`, + ); + + console.log('\n── Recent Activity ────────────────────────────────────────'); + if (profile.recentActivity.length === 0) { + console.log(' No recent operations found for this account.'); + } else { + profile.recentActivity.forEach((entry) => + console.log(` ${entry.at} ${entry.type} ${entry.detail}`), + ); + } + + if (profile.incompleteSections.length > 0) { + console.log('\n── Incomplete Sections ────────────────────────────────────'); + console.log( + ` The following Horizon requests failed: ${profile.incompleteSections.join(', ')}.`, + ); + console.log(' Statistics above exclude those sections and are therefore partial.'); + for (const [name, status] of Object.entries(profile.sections)) { + if (!status.ok) console.log(` ${name}: ${status.error}`); + } + } + + console.log('\n── Scope ──────────────────────────────────────────────────'); + console.log(' • Counts describe retrieved records only, not lifetime account totals.'); + console.log(' • Horizon history retention bounds how far back records go.'); + console.log(' • This profile is descriptive; it does not assess or classify the account.'); + console.log(' • No ledger state was modified.'); +} + +// ────────────────────────────────────────────────────────────────────────────── +// Entry point +// ────────────────────────────────────────────────────────────────────────────── + +function wantsJson(params: RunParams): boolean { + return ( + params.json === true || + params.json === 'true' || + process.env.OUTPUT_FORMAT === 'json' || + process.argv.includes('--json') + ); +} + +/** + * Runs the account activity profile example. + */ +export async function run(params: RunParams = {}): Promise { + const horizonUrl = process.env.HORIZON_URL ?? DEFAULT_HORIZON_URL; + const outputJson = wantsJson(params); + const recordLimit = normalizeLimit(params.limit ?? process.env.RECORD_LIMIT); + + const accountId = + (typeof params.accountId === 'string' && params.accountId.trim()) || + process.env.ACCOUNT_ID?.trim() || + FALLBACK_ACCOUNT_ID; + + console.log('Starting Account Activity Profile Example...'); + console.log(`Using Horizon: ${horizonUrl}`); + + if (!isValidAccountId(accountId)) { + throw new Error( + `Invalid account ID "${accountId}": expected a 56-character G… ed25519 public key.`, + ); + } + + console.log(`Profiling account: ${accountId}`); + console.log(`Retrieving up to ${recordLimit} records per resource...`); + + const server = new Horizon.Server(horizonUrl); + const sections: ActivityProfile['sections'] = { + account: okStatus(), + transactions: okStatus(), + operations: okStatus(), + payments: okStatus(), + }; + + let account: AccountLike | null = null; + try { + account = (await server.loadAccount(accountId)) as unknown as AccountLike; + } catch (error: unknown) { + sections.account = errorStatus(error); + } + + // The account itself must exist for the rest of the profile to mean anything; + // the history sections are optional and degrade independently below. + if (!account) { + throw new Error( + `Could not load account ${accountId} from ${horizonUrl}: ${sections.account.error}. ` + + 'Verify the account exists on this network and has been funded.', + ); + } + + let transactions: TransactionRecord[] = []; + try { + const page = await server + .transactions() + .forAccount(accountId) + .includeFailed(true) + .order('desc') + .limit(recordLimit) + .call(); + transactions = page.records as unknown as TransactionRecord[]; + } catch (error: unknown) { + sections.transactions = errorStatus(error); + } + + let operations: OperationRecord[] = []; + try { + const page = await server + .operations() + .forAccount(accountId) + .includeFailed(true) + .order('desc') + .limit(recordLimit) + .call(); + operations = page.records as unknown as OperationRecord[]; + } catch (error: unknown) { + sections.operations = errorStatus(error); + } + + let payments: PaymentRecord[] = []; + try { + const page = await server + .payments() + .forAccount(accountId) + .order('desc') + .limit(recordLimit) + .call(); + payments = page.records as unknown as PaymentRecord[]; + } catch (error: unknown) { + sections.payments = errorStatus(error); + } + + const profile = buildProfile({ + accountId, + horizonUrl, + recordLimit, + account, + transactions, + operations, + payments, + sections, + }); + + if (outputJson) { + console.log(JSON.stringify(profile, null, 2)); + return; + } + + displayProfile(profile); + console.log('\nAccount activity profile completed.'); +} diff --git a/src/examples/172-horizon-request-tracing.ts b/src/examples/172-horizon-request-tracing.ts new file mode 100644 index 0000000..16a2682 --- /dev/null +++ b/src/examples/172-horizon-request-tracing.ts @@ -0,0 +1,464 @@ +/** + * 172-horizon-request-tracing: Horizon Request Tracing and Diagnostics + * + * OVERVIEW + * -------- + * A single application workflow can issue a dozen Horizon requests. When one of + * them fails or turns slow, the useful questions are always the same: which + * resource was called, how long did it take, what came back, and was it retried? + * + * This example wraps Horizon calls in a small tracing layer that answers those + * questions without changing how the calls themselves are written. + * + * WHAT IS TRACED + * -------------- + * Each traced call records start time, end time, duration, the resource label, + * an HTTP status where one is available, success or failure, the attempt count, + * and a sanitized error message. From those records the tracer derives request + * counts, failure counts, and min/max/average/median latency. + * + * TIMEOUTS AND RETRIES + * -------------------- + * Horizon calls are raced against a configurable timeout so a hung connection + * cannot stall the workflow indefinitely. Note that the timeout abandons the + * *wait*, not the underlying HTTP request — the SDK does not expose a cancel + * signal here, so the socket closes on its own. Failed attempts are retried up + * to a configurable count with a fixed backoff, and every attempt is counted so + * a request that only succeeded on its third try is visible as such. + * + * MEDIAN VS AVERAGE + * ------------------- + * Both are reported because they fail differently. One very slow request drags + * the average far above what a typical call costs, while the median stays + * representative; comparing the two is often the fastest way to spot a single + * outlier hiding in an otherwise healthy set. + * + * SANITIZATION + * ------------ + * Diagnostic output is scrubbed before logging. Stellar secret seeds (`S…`), + * JWTs, and common `token`/`secret`/`authorization`/`api_key` query parameters + * are redacted, so a trace can be pasted into a bug report. Sanitization is a + * safety net, not a licence to feed credentials through the tracer. + * + * This example is read-only and submits nothing to the network. + */ + +import { Horizon, Keypair } from '@stellar/stellar-sdk'; + +const DEFAULT_HORIZON_URL = 'https://horizon-testnet.stellar.org'; +const DEFAULT_TIMEOUT_MS = 10_000; +const DEFAULT_SLOW_REQUEST_MS = 1_000; +const DEFAULT_MAX_ATTEMPTS = 2; +const RETRY_BACKOFF_MS = 250; +const UNREACHABLE_HORIZON_URL = 'https://horizon-testnet.stellar.invalid'; + +// ────────────────────────────────────────────────────────────────────────────── +// Types +// ────────────────────────────────────────────────────────────────────────────── + +export interface RequestTrace { + resource: string; + startedAt: string; + completedAt: string; + durationMs: number; + httpStatus: number | null; + success: boolean; + attempts: number; + retried: boolean; + slow: boolean; + error: string | null; +} + +export interface LatencyStatistics { + count: number; + minMs: number; + maxMs: number; + averageMs: number; + medianMs: number; +} + +export interface DiagnosticSummary { + horizonUrl: string; + totalRequests: number; + successfulRequests: number; + failedRequests: number; + retriedRequests: number; + totalAttempts: number; + slowRequests: number; + slowThresholdMs: number; + timeoutMs: number; + latency: LatencyStatistics; + traces: RequestTrace[]; +} + +export interface TracerOptions { + timeoutMs?: number; + slowRequestMs?: number; + maxAttempts?: number; + retryBackoffMs?: number; + verbose?: boolean; +} + +export interface RunParams { + timeoutMs?: string | number; + slowRequestMs?: string | number; + verbose?: boolean | string; + json?: boolean | string; +} + +// ────────────────────────────────────────────────────────────────────────────── +// Sanitization +// ────────────────────────────────────────────────────────────────────────────── + +const SECRET_SEED_PATTERN = /\bS[A-Z2-7]{55}\b/g; +const JWT_PATTERN = /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g; +const SENSITIVE_QUERY_PATTERN = /\b(token|secret|password|api[_-]?key|authorization)=([^&\s"']+)/gi; + +/** + * Redacts credentials that can appear in Horizon error text or request URLs. + * + * Public keys (`G…`) are deliberately left intact: they are public ledger + * identifiers and removing them would make traces useless for debugging. + */ +export function sanitizeDiagnostic(value: string): string { + return value + .replace(SECRET_SEED_PATTERN, '[REDACTED_SECRET_KEY]') + .replace(JWT_PATTERN, '[REDACTED_JWT]') + .replace(SENSITIVE_QUERY_PATTERN, (_match, key: string) => `${key}=[REDACTED]`); +} + +/** + * Extracts an HTTP status from a Horizon SDK error. + * + * The SDK surfaces the status in different places depending on whether the + * failure came from Horizon itself (`response.status`) or from the underlying + * transport, so several shapes are probed before giving up. + */ +export function extractHttpStatus(error: unknown): number | null { + if (!error || typeof error !== 'object') return null; + const candidate = error as { + response?: { status?: number }; + status?: number; + }; + return candidate.response?.status ?? candidate.status ?? null; +} + +/** Pulls a readable message out of an unknown thrown value, then sanitizes it. */ +export function describeError(error: unknown): string { + const raw = error instanceof Error ? error.message : String(error); + return sanitizeDiagnostic(raw); +} + +// ────────────────────────────────────────────────────────────────────────────── +// Latency statistics +// ────────────────────────────────────────────────────────────────────────────── + +/** Median of a numeric list; even-length lists average the two middle values. */ +export function median(values: number[]): number { + if (values.length === 0) return 0; + const sorted = [...values].sort((a, b) => a - b); + const middle = Math.floor(sorted.length / 2); + return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle]; +} + +/** + * Aggregates latency across traces. + * + * Failed requests are included: a request that took four seconds to time out is + * exactly the kind of latency worth reporting, and excluding it would make a + * failing endpoint look faster than a working one. + */ +export function computeLatency(traces: RequestTrace[]): LatencyStatistics { + const durations = traces.map((trace) => trace.durationMs); + if (durations.length === 0) { + return { count: 0, minMs: 0, maxMs: 0, averageMs: 0, medianMs: 0 }; + } + + return { + count: durations.length, + minMs: Math.min(...durations), + maxMs: Math.max(...durations), + averageMs: durations.reduce((sum, value) => sum + value, 0) / durations.length, + medianMs: median(durations), + }; +} + +/** Builds the aggregate diagnostic summary from a trace set. */ +export function summarizeTraces( + traces: RequestTrace[], + options: { horizonUrl: string; slowThresholdMs: number; timeoutMs: number }, +): DiagnosticSummary { + return { + horizonUrl: options.horizonUrl, + totalRequests: traces.length, + successfulRequests: traces.filter((trace) => trace.success).length, + failedRequests: traces.filter((trace) => !trace.success).length, + retriedRequests: traces.filter((trace) => trace.retried).length, + totalAttempts: traces.reduce((sum, trace) => sum + trace.attempts, 0), + slowRequests: traces.filter((trace) => trace.slow).length, + slowThresholdMs: options.slowThresholdMs, + timeoutMs: options.timeoutMs, + latency: computeLatency(traces), + traces, + }; +} + +// ────────────────────────────────────────────────────────────────────────────── +// Tracer +// ────────────────────────────────────────────────────────────────────────────── + +/** Rejects after `ms`, used to bound how long a Horizon call is awaited. */ +export function timeoutAfter(ms: number, resource: string): Promise { + return new Promise((_resolve, reject) => { + const timer = setTimeout( + () => reject(new Error(`Request to ${resource} timed out after ${ms}ms`)), + ms, + ); + // Do not hold the event loop open on account of a pending timeout. + if (typeof timer.unref === 'function') timer.unref(); + }); +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Records timing and outcome for each traced Horizon call. + * + * The tracer is deliberately transport-agnostic: it wraps any promise-returning + * thunk rather than patching the SDK's HTTP client, so the same wrapper works + * for Horizon builders, raw fetches, or a mock in a test. + */ +export class HorizonRequestTracer { + private readonly traces: RequestTrace[] = []; + private readonly timeoutMs: number; + private readonly slowRequestMs: number; + private readonly maxAttempts: number; + private readonly retryBackoffMs: number; + private readonly verbose: boolean; + + constructor(options: TracerOptions = {}) { + this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + this.slowRequestMs = options.slowRequestMs ?? DEFAULT_SLOW_REQUEST_MS; + this.maxAttempts = Math.max(1, options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS); + this.retryBackoffMs = options.retryBackoffMs ?? RETRY_BACKOFF_MS; + this.verbose = options.verbose ?? false; + } + + /** All traces recorded so far, in call order. */ + getTraces(): RequestTrace[] { + return [...this.traces]; + } + + /** + * Executes `operation`, recording one trace covering all of its attempts. + * + * Resolves with the operation's value on success and with `null` on final + * failure: a diagnostic wrapper that threw would defeat its own purpose by + * aborting the workflow it is meant to be observing. + */ + async trace(resource: string, operation: () => Promise): Promise { + const startedAt = new Date(); + const start = Date.now(); + let attempts = 0; + let lastError: unknown = null; + + while (attempts < this.maxAttempts) { + attempts += 1; + try { + const result = await Promise.race([operation(), timeoutAfter(this.timeoutMs, resource)]); + this.record(resource, startedAt, start, attempts, null, true); + return result as T; + } catch (error: unknown) { + lastError = error; + if (attempts < this.maxAttempts) { + if (this.verbose) { + console.log( + ` retrying ${resource} after attempt ${attempts}: ${describeError(error)}`, + ); + } + await delay(this.retryBackoffMs); + } + } + } + + this.record(resource, startedAt, start, attempts, lastError, false); + return null; + } + + private record( + resource: string, + startedAt: Date, + start: number, + attempts: number, + error: unknown, + success: boolean, + ): void { + const durationMs = Date.now() - start; + const trace: RequestTrace = { + resource: sanitizeDiagnostic(resource), + startedAt: startedAt.toISOString(), + completedAt: new Date().toISOString(), + durationMs, + httpStatus: success ? 200 : extractHttpStatus(error), + success, + attempts, + retried: attempts > 1, + slow: durationMs >= this.slowRequestMs, + error: success ? null : describeError(error), + }; + + this.traces.push(trace); + + if (this.verbose) { + console.log( + ` [${trace.success ? 'ok ' : 'ERR'}] ${trace.resource} ` + + `${trace.durationMs}ms attempts=${trace.attempts}` + + `${trace.slow ? ' SLOW' : ''}` + + `${trace.error ? ` error=${trace.error}` : ''}`, + ); + } + } + + /** Aggregate summary over everything traced so far. */ + summarize(horizonUrl: string): DiagnosticSummary { + return summarizeTraces(this.traces, { + horizonUrl, + slowThresholdMs: this.slowRequestMs, + timeoutMs: this.timeoutMs, + }); + } +} + +// ────────────────────────────────────────────────────────────────────────────── +// Display +// ────────────────────────────────────────────────────────────────────────────── + +function displaySummary(summary: DiagnosticSummary): void { + console.log('\n=== Horizon Request Diagnostics ==='); + console.log(` Horizon: ${summary.horizonUrl}`); + console.log(` Timeout: ${summary.timeoutMs}ms`); + console.log(` Slow threshold: ${summary.slowThresholdMs}ms`); + + console.log('\n── Request Counts ─────────────────────────────────────────'); + console.log(` Total requests: ${summary.totalRequests}`); + console.log(` Successful: ${summary.successfulRequests}`); + console.log(` Failed: ${summary.failedRequests}`); + console.log(` Retried: ${summary.retriedRequests}`); + console.log(` Total attempts: ${summary.totalAttempts}`); + console.log(` Slow requests: ${summary.slowRequests}`); + + console.log('\n── Latency ────────────────────────────────────────────────'); + console.log(` Minimum: ${summary.latency.minMs}ms`); + console.log(` Maximum: ${summary.latency.maxMs}ms`); + console.log(` Average: ${summary.latency.averageMs.toFixed(2)}ms`); + console.log(` Median: ${summary.latency.medianMs.toFixed(2)}ms`); + + console.log('\n── Per-Request Traces ─────────────────────────────────────'); + summary.traces.forEach((trace, index) => { + console.log( + ` ${index + 1}. ${trace.resource}` + + `\n status=${trace.httpStatus ?? 'n/a'} success=${trace.success}` + + ` duration=${trace.durationMs}ms attempts=${trace.attempts}${trace.slow ? ' SLOW' : ''}` + + `\n started=${trace.startedAt} completed=${trace.completedAt}` + + (trace.error ? `\n error=${trace.error}` : ''), + ); + }); + + const slow = summary.traces.filter((trace) => trace.slow); + if (slow.length > 0) { + console.log('\n── Slow Requests ──────────────────────────────────────────'); + slow.forEach((trace) => + console.log(` ${trace.resource}: ${trace.durationMs}ms (>= ${summary.slowThresholdMs}ms)`), + ); + } +} + +// ────────────────────────────────────────────────────────────────────────────── +// Entry point +// ────────────────────────────────────────────────────────────────────────────── + +function wantsJson(params: RunParams): boolean { + return ( + params.json === true || + params.json === 'true' || + process.env.OUTPUT_FORMAT === 'json' || + process.argv.includes('--json') + ); +} + +function parsePositiveInt(value: unknown, fallback: number): number { + const parsed = Number(value); + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; +} + +/** + * Runs the Horizon request tracing example. + */ +export async function run(params: RunParams = {}): Promise { + const horizonUrl = process.env.HORIZON_URL ?? DEFAULT_HORIZON_URL; + const outputJson = wantsJson(params); + const timeoutMs = parsePositiveInt( + params.timeoutMs ?? process.env.REQUEST_TIMEOUT_MS, + DEFAULT_TIMEOUT_MS, + ); + const slowRequestMs = parsePositiveInt( + params.slowRequestMs ?? process.env.SLOW_REQUEST_MS, + DEFAULT_SLOW_REQUEST_MS, + ); + const verbose = + params.verbose === true || params.verbose === 'true' || process.argv.includes('--verbose'); + + console.log('Starting Horizon Request Tracing Example...'); + console.log(`Using Horizon: ${horizonUrl}`); + console.log(`Timeout: ${timeoutMs}ms · Slow threshold: ${slowRequestMs}ms · Verbose: ${verbose}`); + + const server = new Horizon.Server(horizonUrl); + const tracer = new HorizonRequestTracer({ timeoutMs, slowRequestMs, verbose }); + + console.log('\nTracing Horizon resources...'); + + // A spread of resource types, so the summary covers more than one endpoint. + await tracer.trace('GET /ledgers?order=desc&limit=1', () => + server.ledgers().order('desc').limit(1).call(), + ); + await tracer.trace('GET /transactions?order=desc&limit=5', () => + server.transactions().order('desc').limit(5).call(), + ); + await tracer.trace('GET /operations?order=desc&limit=5', () => + server.operations().order('desc').limit(5).call(), + ); + await tracer.trace('GET /fee_stats', () => server.feeStats()); + + // A freshly generated key is well-formed but has never been funded, so Horizon + // answers 404. This shows how an HTTP error is traced and how the status is + // preserved alongside the timing. + const unfundedAccountId = Keypair.random().publicKey(); + await tracer.trace('GET /accounts/{unfunded} (expected 404)', () => + server.loadAccount(unfundedAccountId), + ); + + // A deliberately unresolvable host shows a transport-level failure and, with + // retries enabled, produces a trace with attempts > 1. + const unreachable = new Horizon.Server(UNREACHABLE_HORIZON_URL, { allowHttp: false }); + await tracer.trace('GET /ledgers on unreachable host (expected network error)', () => + unreachable.ledgers().limit(1).call(), + ); + + const summary = tracer.summarize(horizonUrl); + + if (outputJson) { + console.log(JSON.stringify(summary, null, 2)); + return; + } + + displaySummary(summary); + + console.log('\n── Notes ──────────────────────────────────────────────────'); + console.log(' • Traces are sanitized: secret seeds, JWTs, and token params are redacted.'); + console.log(' • Failed requests are included in latency statistics on purpose.'); + console.log(' • Compare median against average to spot a single slow outlier.'); + console.log(' • The timeout bounds the wait, not the underlying socket.'); + console.log('\nHorizon request tracing completed.'); +} diff --git a/src/runner/catalog.ts b/src/runner/catalog.ts index f54b44f..0fbca06 100644 --- a/src/runner/catalog.ts +++ b/src/runner/catalog.ts @@ -522,6 +522,24 @@ export const examples: Record = { description: 'Inspect Soroban contract code metadata, extract the code identifier, and verify a supplied WASM hash', run: loadExample('../examples/192-soroban-contract-code-inspection'), + params: [ + { + type: 'input', + name: 'contractId', + message: 'Contract ID to inspect (blank uses default testnet contract):', + }, + { + type: 'input', + name: 'expectedHash', + message: 'Optional expected code hash (hex) for verification:', + }, + { + type: 'input', + name: 'wasmFile', + message: 'Optional path to WASM file to hash and compare:', + }, + ], + }, '66-ledger-effects': { name: '66-ledger-effects', description: @@ -550,20 +568,6 @@ export const examples: Record = { { type: 'input', name: 'contractId', - message: 'Contract ID to inspect (blank uses default testnet contract):', - }, - { - type: 'input', - name: 'expectedHash', - message: 'Optional expected code hash (hex) for verification:', - }, - { - type: 'input', - name: 'wasmFile', - message: 'Optional path to WASM file to hash and compare:', - }, - ], - }, message: 'Contract ID (blank discovers a recently active contract):', }, { @@ -1488,7 +1492,8 @@ export const examples: Record = { }, '163-account-transaction-history': { name: '163-account-transaction-history', - description: 'Inspect a Stellar account transaction history and summarize recent transaction flow', + description: + 'Inspect a Stellar account transaction history and summarize recent transaction flow', run: loadExample('../examples/163-account-transaction-history'), }, '164-account-operation-history': { @@ -1516,4 +1521,92 @@ export const examples: Record = { description: 'Inspect asset authorization flags and trustline authorization-related balances', run: loadExample('../examples/168-issuer-authorization-inspection'), }, + '169-asset-holder-distribution': { + name: '169-asset-holder-distribution', + description: + 'Analyse how an issued asset is distributed across its trustline holders, with ranking and concentration metrics', + run: loadExample('../examples/169-asset-holder-distribution'), + params: [ + { + type: 'input', + name: 'assetCode', + message: 'Asset code (blank uses a default Testnet asset):', + }, + { + type: 'input', + name: 'assetIssuer', + message: 'Issuer account ID (blank uses a default Testnet issuer):', + }, + { + type: 'input', + name: 'maxHolders', + message: 'Maximum holders to retrieve:', + default: '400', + }, + { + type: 'input', + name: 'topN', + message: 'Number of top holders to display:', + default: '10', + }, + ], + }, + '170-account-balance-liability-analysis': { + name: '170-account-balance-liability-analysis', + description: + 'Analyse account balances, trustline limits, buying and selling liabilities, and derived available amounts', + run: loadExample('../examples/170-account-balance-liability-analysis'), + params: [ + { + type: 'input', + name: 'accountId', + message: 'Account ID (blank uses a well-known Testnet account):', + }, + ], + }, + '171-account-activity-profile': { + name: '171-account-activity-profile', + description: + 'Build a read-only activity profile combining account metadata, transaction, operation, and payment history', + run: loadExample('../examples/171-account-activity-profile'), + params: [ + { + type: 'input', + name: 'accountId', + message: 'Account ID (blank uses a well-known Testnet account):', + }, + { + type: 'input', + name: 'limit', + message: 'Records to retrieve per resource (1-200):', + default: '50', + }, + ], + }, + '172-horizon-request-tracing': { + name: '172-horizon-request-tracing', + description: + 'Trace Horizon requests with timing, retry, HTTP status, and latency diagnostics, with sanitized output', + run: loadExample('../examples/172-horizon-request-tracing'), + params: [ + { + type: 'input', + name: 'timeoutMs', + message: 'Request timeout in milliseconds:', + default: '10000', + }, + { + type: 'input', + name: 'slowRequestMs', + message: 'Slow-request threshold in milliseconds:', + default: '1000', + }, + { + type: 'confirm', + name: 'verbose', + message: 'Log each request as it completes?', + default: false, + }, + ], + }, }; diff --git a/src/validation/validation.config.json b/src/validation/validation.config.json index 7e6bd15..967ddf6 100644 --- a/src/validation/validation.config.json +++ b/src/validation/validation.config.json @@ -129,6 +129,8 @@ { "match": "192-soroban-contract-code-inspection", "reason": "Requires Soroban RPC availability and a deployed contract instance" + }, + { "match": "66-ledger-effects", "reason": "Requires live Horizon ledger and effect data" }, @@ -211,6 +213,22 @@ { "match": "159-horizon-stream-filtering", "reason": "Keeps a live Horizon SSE connection open for client-side filtering" + }, + { + "match": "169-asset-holder-distribution", + "reason": "Requires live Horizon trustline data across paginated account pages" + }, + { + "match": "170-account-balance-liability-analysis", + "reason": "Requires live Horizon account balance and liability data" + }, + { + "match": "171-account-activity-profile", + "reason": "Requires live Horizon account, transaction, operation, and payment history" + }, + { + "match": "172-horizon-request-tracing", + "reason": "Issues live Horizon requests, including deliberate failure and timeout cases" } ] } diff --git a/tests/account-activity-profile.test.ts b/tests/account-activity-profile.test.ts new file mode 100644 index 0000000..35a9154 --- /dev/null +++ b/tests/account-activity-profile.test.ts @@ -0,0 +1,308 @@ +import { + buildProfile, + buildRecentActivity, + isValidAccountId, + normalizeLimit, + observedWindowHours, + summarizeBalances, + summarizeOperations, + summarizePayments, + summarizeTransactions, + type ActivityProfile, + type OperationRecord, + type PaymentRecord, + type TransactionRecord, +} from '../src/examples/171-account-activity-profile'; +import { examples } from '../src/runner/catalog'; + +const ACCOUNT = 'GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN7'; +const OTHER = 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5'; +const HORIZON = 'https://horizon-testnet.stellar.org'; + +const okSections = (): ActivityProfile['sections'] => ({ + account: { ok: true, error: null }, + transactions: { ok: true, error: null }, + operations: { ok: true, error: null }, + payments: { ok: true, error: null }, +}); + +const transactions: TransactionRecord[] = [ + { + hash: 'a', + ledger: 100, + created_at: '2026-01-01T00:00:00Z', + successful: true, + fee_charged: '100', + operation_count: 2, + }, + { + hash: 'b', + ledger: 100, + created_at: '2026-01-01T12:00:00Z', + successful: false, + fee_charged: '200', + operation_count: 1, + }, + { + hash: 'c', + ledger: 101, + created_at: '2026-01-02T00:00:00Z', + successful: true, + fee_charged: '300', + operation_count: 1, + }, +]; + +describe('Issue #231 / ISSUE-171: input handling', () => { + it('validates account IDs', () => { + expect(isValidAccountId(ACCOUNT)).toBe(true); + expect(isValidAccountId('GNOPE')).toBe(false); + }); + + it("clamps the record limit into Horizon's accepted range", () => { + expect(normalizeLimit(25)).toBe(25); + expect(normalizeLimit('75')).toBe(75); + expect(normalizeLimit(5000)).toBe(200); + expect(normalizeLimit(0)).toBe(50); + expect(normalizeLimit('abc')).toBe(50); + expect(normalizeLimit(undefined)).toBe(50); + }); +}); + +describe('Issue #231 / ISSUE-171: transaction statistics', () => { + it('counts successful and failed transactions', () => { + const stats = summarizeTransactions(transactions); + expect(stats.total).toBe(3); + expect(stats.successful).toBe(2); + expect(stats.failed).toBe(1); + expect(stats.successRate).toBeCloseTo(66.6667, 3); + }); + + it('aggregates fees in stroops and XLM', () => { + const stats = summarizeTransactions(transactions); + expect(stats.totalFeeStroops).toBe(600); + expect(stats.totalFeeXlm).toBeCloseTo(0.00006, 10); + expect(stats.averageFeeStroops).toBe(200); + }); + + it('ignores unusable fee values instead of producing NaN', () => { + const stats = summarizeTransactions([{ fee_charged: undefined }, { fee_charged: 'x' }]); + expect(stats.totalFeeStroops).toBe(0); + }); + + it('counts distinct active ledgers and the observed time window', () => { + const stats = summarizeTransactions(transactions); + expect(stats.activeLedgers).toBe(2); + expect(stats.firstObservedAt).toBe('2026-01-01T00:00:00Z'); + expect(stats.lastObservedAt).toBe('2026-01-02T00:00:00Z'); + expect(stats.operationCountFromTransactions).toBe(4); + }); + + it('handles an empty transaction history', () => { + const stats = summarizeTransactions([]); + expect(stats).toMatchObject({ total: 0, successful: 0, failed: 0, successRate: 0 }); + expect(stats.firstObservedAt).toBeNull(); + }); +}); + +describe('Issue #231 / ISSUE-171: operation statistics', () => { + const operations: OperationRecord[] = [ + { type: 'payment', created_at: '2026-01-01T00:00:00Z' }, + { type: 'payment', created_at: '2026-01-02T00:00:00Z' }, + { type: 'create_account', created_at: '2026-01-03T00:00:00Z' }, + { created_at: '2026-01-04T00:00:00Z' }, + ]; + + it('groups operations by type', () => { + const stats = summarizeOperations(operations); + expect(stats.total).toBe(4); + expect(stats.byType).toEqual({ payment: 2, create_account: 1, unknown: 1 }); + }); + + it('ranks the most frequent operation types', () => { + const stats = summarizeOperations(operations); + expect(stats.mostFrequentTypes[0]).toEqual({ type: 'payment', count: 2 }); + expect(stats.mostFrequentTypes).toHaveLength(3); + }); + + it('handles no operations', () => { + expect(summarizeOperations([])).toEqual({ total: 0, byType: {}, mostFrequentTypes: [] }); + }); + + it('lists recent activity newest first', () => { + const recent = buildRecentActivity(operations, 2); + expect(recent).toHaveLength(2); + expect(recent[0].at).toBe('2026-01-04T00:00:00Z'); + expect(recent[1].at).toBe('2026-01-03T00:00:00Z'); + }); + + it('marks failed transactions in recent activity', () => { + const recent = buildRecentActivity([ + { type: 'payment', created_at: '2026-01-01T00:00:00Z', transaction_successful: false }, + ]); + expect(recent[0].detail).toContain('transaction failed'); + }); +}); + +describe('Issue #231 / ISSUE-171: payment statistics', () => { + const payments: PaymentRecord[] = [ + { type: 'payment', from: OTHER, to: ACCOUNT, asset_type: 'native', amount: '10' }, + { + type: 'payment', + from: ACCOUNT, + to: OTHER, + asset_type: 'credit_alphanum4', + asset_code: 'USDC', + }, + { type: 'create_account', source_account: OTHER, to: ACCOUNT, asset_type: 'native' }, + ]; + + it('splits payments into incoming and outgoing', () => { + const stats = summarizePayments(payments, ACCOUNT); + expect(stats.total).toBe(3); + expect(stats.incoming).toBe(2); + expect(stats.outgoing).toBe(1); + }); + + it('falls back to the source account when `from` is absent', () => { + const stats = summarizePayments([{ source_account: ACCOUNT, to: OTHER }], ACCOUNT); + expect(stats.outgoing).toBe(1); + expect(stats.incoming).toBe(0); + }); + + it('tracks self-payments in both directions without hiding them', () => { + const stats = summarizePayments([{ from: ACCOUNT, to: ACCOUNT }], ACCOUNT); + expect(stats.incoming).toBe(1); + expect(stats.outgoing).toBe(1); + expect(stats.selfPayments).toBe(1); + }); + + it('groups payments by asset', () => { + const stats = summarizePayments(payments, ACCOUNT); + expect(stats.byAsset).toEqual({ XLM: 2, USDC: 1 }); + }); + + it('handles no payments', () => { + expect(summarizePayments([], ACCOUNT)).toEqual({ + total: 0, + incoming: 0, + outgoing: 0, + selfPayments: 0, + byAsset: {}, + }); + }); +}); + +describe('Issue #231 / ISSUE-171: balances and activity window', () => { + it('summarizes balances and trustlines', () => { + const summary = summarizeBalances({ + balances: [ + { asset_type: 'native', balance: '100' }, + { asset_type: 'credit_alphanum4', asset_code: 'USDC', balance: '5' }, + ], + }); + expect(summary).toEqual({ + balanceCount: 2, + nativeBalance: '100', + trustlineCount: 1, + assets: ['USDC'], + }); + }); + + it('handles an account with no balances', () => { + expect(summarizeBalances({})).toEqual({ + balanceCount: 0, + nativeBalance: null, + trustlineCount: 0, + assets: [], + }); + }); + + it('measures the observed window in hours', () => { + expect(observedWindowHours(summarizeTransactions(transactions))).toBeCloseTo(24, 6); + }); + + it('returns null when the window cannot be measured', () => { + expect(observedWindowHours(summarizeTransactions([]))).toBeNull(); + expect( + observedWindowHours( + summarizeTransactions([{ created_at: '2026-01-01T00:00:00Z', successful: true }]), + ), + ).toBeNull(); + }); +}); + +describe('Issue #231 / ISSUE-171: profile assembly', () => { + const baseInput = { + accountId: ACCOUNT, + horizonUrl: HORIZON, + recordLimit: 50, + account: { account_id: ACCOUNT, sequence: '42', subentry_count: 1, balances: [] }, + transactions, + operations: [{ type: 'payment', created_at: '2026-01-01T00:00:00Z' }], + payments: [{ from: OTHER, to: ACCOUNT }], + sections: okSections(), + }; + + it('assembles a complete profile', () => { + const profile = buildProfile(baseInput); + expect(profile.accountId).toBe(ACCOUNT); + expect(profile.account.sequence).toBe('42'); + expect(profile.transactions.total).toBe(3); + expect(profile.operations.total).toBe(1); + expect(profile.payments.incoming).toBe(1); + expect(profile.incompleteSections).toEqual([]); + }); + + it('derives operations per transaction and an activity rate', () => { + const profile = buildProfile(baseInput); + expect(profile.derived.operationsPerTransaction).toBeCloseTo(1 / 3, 6); + expect(profile.derived.observedWindowHours).toBeCloseTo(24, 6); + expect(profile.derived.transactionsPerDay).toBeCloseTo(3, 6); + }); + + it('lists sections that failed rather than reporting silent zeros', () => { + const profile = buildProfile({ + ...baseInput, + operations: [], + payments: [], + sections: { + ...okSections(), + operations: { ok: false, error: 'rate limited' }, + payments: { ok: false, error: 'timeout' }, + }, + }); + expect(profile.incompleteSections).toEqual(['operations', 'payments']); + expect(profile.sections.operations.error).toBe('rate limited'); + }); + + it('handles an account with no history', () => { + const profile = buildProfile({ + ...baseInput, + transactions: [], + operations: [], + payments: [], + }); + expect(profile.transactions.total).toBe(0); + expect(profile.derived.operationsPerTransaction).toBe(0); + expect(profile.derived.transactionsPerDay).toBeNull(); + expect(profile.recentActivity).toEqual([]); + }); + + it('produces a complete machine-readable profile', () => { + const parsed = JSON.parse(JSON.stringify(buildProfile(baseInput))); + expect(parsed.accountId).toBe(ACCOUNT); + expect(parsed.transactions.totalFeeStroops).toBe(600); + expect(parsed.sections.account.ok).toBe(true); + expect(typeof parsed.observedAt).toBe('string'); + }); +}); + +describe('Issue #231 / ISSUE-171: runner registration', () => { + it('registers the example with account and limit parameters', () => { + const entry = examples['171-account-activity-profile']; + expect(entry).toBeDefined(); + expect(typeof entry.run).toBe('function'); + expect(entry.params?.map((param) => param.name)).toEqual(['accountId', 'limit']); + }); +}); diff --git a/tests/account-balance-liability-analysis.test.ts b/tests/account-balance-liability-analysis.test.ts new file mode 100644 index 0000000..b5bea7d --- /dev/null +++ b/tests/account-balance-liability-analysis.test.ts @@ -0,0 +1,227 @@ +import { + analyseAccount, + analysePosition, + calculateMinimumReserve, + describeAsset, + formatAmount, + isValidAccountId, + parseAmount, + type AccountLike, + type BalanceLine, +} from '../src/examples/170-account-balance-liability-analysis'; +import { examples } from '../src/runner/catalog'; + +const ACCOUNT = 'GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN7'; +const ISSUER = 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5'; +const HORIZON = 'https://horizon-testnet.stellar.org'; + +function nativeLine(overrides: Partial = {}): BalanceLine { + return { + asset_type: 'native', + balance: '100.0000000', + buying_liabilities: '0.0000000', + selling_liabilities: '0.0000000', + ...overrides, + }; +} + +function issuedLine(overrides: Partial = {}): BalanceLine { + return { + asset_type: 'credit_alphanum4', + asset_code: 'USDC', + asset_issuer: ISSUER, + balance: '500.0000000', + limit: '1000.0000000', + buying_liabilities: '0.0000000', + selling_liabilities: '0.0000000', + ...overrides, + }; +} + +function account(overrides: Partial = {}): AccountLike { + return { + account_id: ACCOUNT, + sequence: '123456789', + subentry_count: 2, + num_sponsored: 0, + num_sponsoring: 0, + balances: [nativeLine(), issuedLine()], + ...overrides, + }; +} + +describe('Issue #230 / ISSUE-170: input validation', () => { + it('accepts a valid account ID and rejects malformed input', () => { + expect(isValidAccountId(ACCOUNT)).toBe(true); + expect(isValidAccountId('GINVALID')).toBe(false); + expect(isValidAccountId(null)).toBe(false); + }); + + it('parses and formats Horizon amounts', () => { + expect(parseAmount('3.5000000')).toBe(3.5); + expect(parseAmount('nope')).toBe(0); + expect(formatAmount(3.5)).toBe('3.5000000'); + }); + + it('labels native, issued, and pool-share balance lines', () => { + expect(describeAsset(nativeLine())).toBe('XLM (native)'); + expect(describeAsset(issuedLine())).toBe('USDC'); + expect(describeAsset({ asset_type: 'liquidity_pool_shares' })).toBe('Liquidity pool shares'); + }); +}); + +describe('Issue #230 / ISSUE-170: minimum reserve', () => { + it('charges the base entries plus each subentry', () => { + expect(calculateMinimumReserve(0)).toBe(1); + expect(calculateMinimumReserve(2)).toBe(2); + }); + + it('exempts sponsored entries and charges sponsored-for-others entries', () => { + expect(calculateMinimumReserve(4, 2, 0)).toBe(2); + expect(calculateMinimumReserve(4, 0, 2)).toBe(4); + }); + + it('never returns a negative reserve', () => { + expect(calculateMinimumReserve(0, 10, 0)).toBe(0); + }); +}); + +describe('Issue #230 / ISSUE-170: position analysis', () => { + it('separates ledger-reported values from derived ones', () => { + const position = analysePosition( + issuedLine({ buying_liabilities: '100', selling_liabilities: '50' }), + ); + expect(position.reported.balance).toBe(500); + expect(position.reported.buyingLiabilities).toBe(100); + expect(position.reported.sellingLiabilities).toBe(50); + expect(position.derived.totalLiabilities).toBe(150); + expect(position.derived.availableAmount).toBe(450); + }); + + it('deducts the minimum reserve from the native balance only', () => { + const native = analysePosition(nativeLine({ selling_liabilities: '10' }), 5); + expect(native.derived.reserveDeduction).toBe(5); + expect(native.derived.availableAmount).toBe(85); + + const issued = analysePosition(issuedLine()); + expect(issued.derived.reserveDeduction).toBe(0); + }); + + it('floors the available amount at zero when everything is committed', () => { + const position = analysePosition(nativeLine({ balance: '5', selling_liabilities: '10' }), 2); + expect(position.derived.availableAmount).toBe(0); + }); + + it('computes trustline utilization and headroom from balance plus buying liabilities', () => { + const position = analysePosition( + issuedLine({ balance: '500', limit: '1000', buying_liabilities: '200' }), + ); + expect(position.derived.limitUtilization).toBeCloseTo(70, 6); + expect(position.derived.limitHeadroom).toBe(300); + expect(position.derived.nearLimit).toBe(false); + }); + + it('flags a trustline at or above 90% of its limit', () => { + const position = analysePosition(issuedLine({ balance: '950', limit: '1000' })); + expect(position.derived.nearLimit).toBe(true); + }); + + it('reports no limit metrics for the native balance', () => { + const position = analysePosition(nativeLine()); + expect(position.reported.limit).toBeNull(); + expect(position.derived.limitUtilization).toBeNull(); + expect(position.derived.limitHeadroom).toBeNull(); + expect(position.derived.nearLimit).toBe(false); + }); + + it('treats absent authorization flags as authorized', () => { + expect(analysePosition(issuedLine()).reported.authorized).toBe(true); + expect(analysePosition(issuedLine({ is_authorized: false })).reported.authorized).toBe(false); + expect( + analysePosition(issuedLine({ is_authorized_to_maintain_liabilities: false })).reported + .authorizedToMaintainLiabilities, + ).toBe(false); + }); + + it('flags liabilities that are significant relative to the balance', () => { + const significant = analysePosition(issuedLine({ balance: '100', selling_liabilities: '80' })); + expect(significant.derived.significantLiabilities).toBe(true); + + const modest = analysePosition(issuedLine({ balance: '100', selling_liabilities: '5' })); + expect(modest.derived.significantLiabilities).toBe(false); + }); +}); + +describe('Issue #230 / ISSUE-170: account analysis', () => { + it('identifies the native balance and issued assets separately', () => { + const report = analyseAccount(account(), HORIZON); + expect(report.accountId).toBe(ACCOUNT); + expect(report.native?.isNative).toBe(true); + expect(report.derived.issuedAssetCount).toBe(1); + expect(report.issuedAssets[0].assetCode).toBe('USDC'); + }); + + it('computes account-level derived totals', () => { + const report = analyseAccount( + account({ + subentry_count: 2, + balances: [nativeLine({ balance: '100', selling_liabilities: '10' }), issuedLine()], + }), + HORIZON, + ); + expect(report.derived.minimumReserveXlm).toBe(2); + expect(report.derived.nativeBalance).toBe(100); + expect(report.derived.nativeAvailable).toBe(88); + expect(report.derived.nativeSellingLiabilities).toBe(10); + }); + + it('handles accounts with no issued assets', () => { + const report = analyseAccount(account({ balances: [nativeLine()] }), HORIZON); + expect(report.issuedAssets).toEqual([]); + expect(report.derived.issuedAssetCount).toBe(0); + expect(report.warnings).toContain( + 'Account holds no issued assets; only the native balance is analysed.', + ); + }); + + it('warns about unauthorized trustlines', () => { + const report = analyseAccount( + account({ balances: [nativeLine(), issuedLine({ is_authorized: false })] }), + HORIZON, + ); + expect(report.derived.unauthorizedTrustlineCount).toBe(1); + expect(report.warnings.some((w) => w.includes('not authorized'))).toBe(true); + }); + + it('warns about trustlines approaching their limit', () => { + const report = analyseAccount( + account({ balances: [nativeLine(), issuedLine({ balance: '999', limit: '1000' })] }), + HORIZON, + ); + expect(report.derived.nearLimitTrustlineCount).toBe(1); + expect(report.warnings.some((w) => w.includes('op_line_full'))).toBe(true); + }); + + it('handles an account with no balance lines at all', () => { + const report = analyseAccount({ account_id: ACCOUNT, balances: [] }, HORIZON); + expect(report.native).toBeNull(); + expect(report.derived.nativeAvailable).toBe(0); + expect(report.warnings.some((w) => w.includes('no native XLM balance line'))).toBe(true); + }); + + it('produces machine-readable JSON', () => { + const parsed = JSON.parse(JSON.stringify(analyseAccount(account(), HORIZON))); + expect(parsed.accountId).toBe(ACCOUNT); + expect(parsed.derived.minimumReserveXlm).toBe(2); + expect(parsed.native.reported.balance).toBe(100); + }); +}); + +describe('Issue #230 / ISSUE-170: runner registration', () => { + it('registers the example with an account ID parameter', () => { + const entry = examples['170-account-balance-liability-analysis']; + expect(entry).toBeDefined(); + expect(typeof entry.run).toBe('function'); + expect(entry.params?.map((param) => param.name)).toEqual(['accountId']); + }); +}); diff --git a/tests/asset-holder-distribution.test.ts b/tests/asset-holder-distribution.test.ts new file mode 100644 index 0000000..ccdb684 --- /dev/null +++ b/tests/asset-holder-distribution.test.ts @@ -0,0 +1,342 @@ +import { + buildAsset, + buildReport, + computeConcentration, + computeStatistics, + extractHolder, + extractHolders, + fetchAllHolders, + formatAmount, + isValidAccountId, + isValidAssetCode, + median, + parseAmount, + percentOf, + rankHolders, + type AssetHolder, +} from '../src/examples/169-asset-holder-distribution'; +import { examples } from '../src/runner/catalog'; + +const ISSUER = 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5'; +const HOLDER_A = 'GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN7'; + +function makeAccount(accountId: string, balance: string, overrides: Record = {}) { + return { + account_id: accountId, + balances: [ + { asset_type: 'native', balance: '100' } as Record, + { + asset_type: 'credit_alphanum4', + asset_code: 'USDC', + asset_issuer: ISSUER, + balance, + limit: '1000', + buying_liabilities: '0', + selling_liabilities: '0', + ...overrides, + } as Record, + ], + }; +} + +function holder(account: string, balance: number, authorized = true): AssetHolder { + return { + account, + balance, + limit: 1000, + authorized, + authorizedToMaintainLiabilities: true, + buyingLiabilities: 0, + sellingLiabilities: 0, + }; +} + +describe('Issue #229 / ISSUE-169: asset validation', () => { + it('accepts alphanumeric-4 and alphanumeric-12 asset codes', () => { + expect(isValidAssetCode('USDC')).toBe(true); + expect(isValidAssetCode('LONGASSET123')).toBe(true); + expect(isValidAssetCode('A')).toBe(true); + }); + + it('rejects malformed asset codes', () => { + expect(isValidAssetCode('')).toBe(false); + expect(isValidAssetCode('THIRTEENCHARS')).toBe(false); + expect(isValidAssetCode('BAD-CODE')).toBe(false); + expect(isValidAssetCode(42)).toBe(false); + }); + + it('validates issuer account IDs', () => { + expect(isValidAccountId(ISSUER)).toBe(true); + expect(isValidAccountId('not-an-account')).toBe(false); + expect(isValidAccountId(undefined)).toBe(false); + }); + + it('builds an asset from a valid code and issuer', () => { + const asset = buildAsset('USDC', ISSUER); + expect(asset.getCode()).toBe('USDC'); + expect(asset.getIssuer()).toBe(ISSUER); + }); + + it('raises actionable errors for invalid identifiers', () => { + expect(() => buildAsset('BAD-CODE', ISSUER)).toThrow(/Invalid asset code/); + expect(() => buildAsset('USDC', 'nope')).toThrow(/Invalid issuer account/); + }); +}); + +describe('Issue #229 / ISSUE-169: amount parsing', () => { + it('parses Horizon amount strings and falls back to zero', () => { + expect(parseAmount('12.5000000')).toBe(12.5); + expect(parseAmount(undefined)).toBe(0); + expect(parseAmount('not-a-number')).toBe(0); + }); + + it('formats amounts with 7 decimals', () => { + expect(formatAmount(12.5)).toBe('12.5000000'); + }); +}); + +describe('Issue #229 / ISSUE-169: trustline extraction', () => { + it('extracts the matching trustline from an account record', () => { + const extracted = extractHolder(makeAccount(HOLDER_A, '250.5'), 'USDC', ISSUER); + expect(extracted).toEqual({ + account: HOLDER_A, + balance: 250.5, + limit: 1000, + authorized: true, + authorizedToMaintainLiabilities: true, + buyingLiabilities: 0, + sellingLiabilities: 0, + }); + }); + + it('extracts liabilities and authorization flags', () => { + const extracted = extractHolder( + makeAccount(HOLDER_A, '10', { + is_authorized: false, + is_authorized_to_maintain_liabilities: false, + buying_liabilities: '3', + selling_liabilities: '4', + }), + 'USDC', + ISSUER, + ); + expect(extracted?.authorized).toBe(false); + expect(extracted?.authorizedToMaintainLiabilities).toBe(false); + expect(extracted?.buyingLiabilities).toBe(3); + expect(extracted?.sellingLiabilities).toBe(4); + }); + + it('does not match a same-coded asset from a different issuer', () => { + const other = makeAccount(HOLDER_A, '5'); + other.balances[1].asset_issuer = 'GDIFFERENTISSUER'; + expect(extractHolder(other, 'USDC', ISSUER)).toBeNull(); + }); + + it('skips records with no matching trustline or no account id', () => { + expect(extractHolder({ balances: [] }, 'USDC', ISSUER)).toBeNull(); + expect(extractHolders([{ account_id: HOLDER_A, balances: [] }], 'USDC', ISSUER)).toEqual([]); + }); +}); + +describe('Issue #229 / ISSUE-169: aggregation', () => { + it('computes the median for odd and even length sets', () => { + expect(median([3, 1, 2])).toBe(2); + expect(median([4, 1, 3, 2])).toBe(2.5); + expect(median([])).toBe(0); + }); + + it('guards percentage calculations against a zero supply', () => { + expect(percentOf(5, 20)).toBe(25); + expect(percentOf(5, 0)).toBe(0); + }); + + it('aggregates balances, averages, and medians', () => { + const stats = computeStatistics([holder('A', 100), holder('B', 50), holder('C', 10)]); + expect(stats.holderCount).toBe(3); + expect(stats.totalBalance).toBe(160); + expect(stats.averageBalance).toBeCloseTo(53.3333333, 6); + expect(stats.medianBalance).toBe(50); + expect(stats.largestBalance).toBe(100); + }); + + it('counts zero-balance trustlines without distorting the total', () => { + const stats = computeStatistics([holder('A', 100), holder('B', 0), holder('C', 0)]); + expect(stats.totalBalance).toBe(100); + expect(stats.zeroBalanceHolderCount).toBe(2); + expect(stats.nonZeroHolderCount).toBe(1); + }); + + it('separates authorized and unauthorized balances', () => { + const stats = computeStatistics([ + holder('A', 100, true), + holder('B', 40, false), + holder('C', 10, false), + ]); + expect(stats.authorizedHolderCount).toBe(1); + expect(stats.unauthorizedHolderCount).toBe(2); + expect(stats.authorizedBalance).toBe(100); + expect(stats.unauthorizedBalance).toBe(50); + }); + + it('handles an empty holder set', () => { + const stats = computeStatistics([]); + expect(stats).toMatchObject({ + holderCount: 0, + totalBalance: 0, + averageBalance: 0, + medianBalance: 0, + largestBalance: 0, + }); + }); +}); + +describe('Issue #229 / ISSUE-169: ranking and concentration', () => { + it('ranks holders by balance, largest first, without mutating the input', () => { + const holders = [holder('A', 10), holder('B', 100), holder('C', 50)]; + const ranked = rankHolders(holders); + expect(ranked.map((entry) => entry.account)).toEqual(['B', 'C', 'A']); + expect(ranked[0].rank).toBe(1); + expect(ranked[0].percentOfTotal).toBeCloseTo(62.5, 6); + expect(holders[0].account).toBe('A'); + }); + + it('computes cumulative top-N concentration', () => { + const ranked = rankHolders([holder('A', 60), holder('B', 30), holder('C', 10)]); + const tiers = computeConcentration(ranked, 100, [1, 2, 3]); + expect(tiers).toEqual([ + { topN: 1, balance: 60, percentOfTotal: 60 }, + { topN: 2, balance: 90, percentOfTotal: 90 }, + { topN: 3, balance: 100, percentOfTotal: 100 }, + ]); + }); + + it('clamps tiers larger than the holder count and de-duplicates them', () => { + const ranked = rankHolders([holder('A', 60), holder('B', 40)]); + const tiers = computeConcentration(ranked, 100, [1, 5, 10]); + expect(tiers).toHaveLength(2); + expect(tiers[1]).toEqual({ topN: 2, balance: 100, percentOfTotal: 100 }); + }); + + it('returns no tiers when there are no holders', () => { + expect(computeConcentration([], 0, [1, 5])).toEqual([]); + }); +}); + +describe('Issue #229 / ISSUE-169: report assembly', () => { + it('builds a report limited to the requested number of top holders', () => { + const report = buildReport([holder('A', 30), holder('B', 20), holder('C', 10)], { + assetCode: 'USDC', + assetIssuer: ISSUER, + horizonUrl: 'https://horizon-testnet.stellar.org', + topN: 2, + pagesFetched: 1, + }); + expect(report.topHolders).toHaveLength(2); + expect(report.statistics.totalBalance).toBe(60); + expect(report.paginationError).toBeNull(); + }); + + it('reports an empty distribution for an asset with no holders', () => { + const report = buildReport([], { + assetCode: 'USDC', + assetIssuer: ISSUER, + horizonUrl: 'https://horizon-testnet.stellar.org', + }); + expect(report.statistics.holderCount).toBe(0); + expect(report.topHolders).toEqual([]); + expect(report.concentration).toEqual([]); + }); + + it('produces machine-readable JSON', () => { + const report = buildReport([holder('A', 30)], { + assetCode: 'USDC', + assetIssuer: ISSUER, + horizonUrl: 'https://horizon-testnet.stellar.org', + }); + const parsed = JSON.parse(JSON.stringify(report)); + expect(parsed.statistics.totalBalance).toBe(30); + expect(parsed.assetCode).toBe('USDC'); + }); +}); + +describe('Issue #229 / ISSUE-169: pagination', () => { + const asset = buildAsset('USDC', ISSUER); + + function serverReturning(pages: any[][], failAtPage?: number): any { + let index = 0; + const makePage = (): any => { + if (failAtPage !== undefined && index === failAtPage) { + throw new Error('Horizon page request failed'); + } + const records = pages[index] ?? []; + index += 1; + return { + records, + next: async () => makePage(), + }; + }; + + return { + accounts: () => ({ + forAsset: () => ({ + limit: () => ({ + call: async () => makePage(), + }), + }), + }), + }; + } + + it('follows pages until an empty page is returned', async () => { + const server = serverReturning([ + [makeAccount(HOLDER_A, '10')], + [makeAccount(HOLDER_A, '20')], + [], + ]); + const result = await fetchAllHolders(server, asset, 100); + expect(result.holders).toHaveLength(2); + expect(result.pagesFetched).toBe(2); + expect(result.truncated).toBe(false); + expect(result.paginationError).toBeNull(); + }); + + it('stops and marks the result truncated at the holder cap', async () => { + const server = serverReturning([ + [makeAccount(HOLDER_A, '10'), makeAccount(HOLDER_A, '20')], + [makeAccount(HOLDER_A, '30')], + ]); + const result = await fetchAllHolders(server, asset, 2); + expect(result.holders).toHaveLength(2); + expect(result.truncated).toBe(true); + }); + + it('reports a mid-stream pagination failure while keeping earlier holders', async () => { + const server = serverReturning([[makeAccount(HOLDER_A, '10')], []], 1); + const result = await fetchAllHolders(server, asset, 100); + expect(result.holders).toHaveLength(1); + expect(result.paginationError).toMatch(/Horizon page request failed/); + }); + + it('reports a first-page failure with no holders', async () => { + const server = serverReturning([[]], 0); + const result = await fetchAllHolders(server, asset, 100); + expect(result.holders).toEqual([]); + expect(result.pagesFetched).toBe(0); + expect(result.paginationError).toMatch(/Horizon page request failed/); + }); +}); + +describe('Issue #229 / ISSUE-169: runner registration', () => { + it('registers the example with configurable parameters', () => { + const entry = examples['169-asset-holder-distribution']; + expect(entry).toBeDefined(); + expect(entry.name).toBe('169-asset-holder-distribution'); + expect(typeof entry.run).toBe('function'); + expect(entry.params?.map((param) => param.name)).toEqual([ + 'assetCode', + 'assetIssuer', + 'maxHolders', + 'topN', + ]); + }); +}); diff --git a/tests/horizon-request-tracing.test.ts b/tests/horizon-request-tracing.test.ts new file mode 100644 index 0000000..97b890e --- /dev/null +++ b/tests/horizon-request-tracing.test.ts @@ -0,0 +1,282 @@ +import { + computeLatency, + describeError, + extractHttpStatus, + HorizonRequestTracer, + median, + sanitizeDiagnostic, + summarizeTraces, + timeoutAfter, + type RequestTrace, +} from '../src/examples/172-horizon-request-tracing'; +import { examples } from '../src/runner/catalog'; + +const SECRET_SEED = 'SCZANGBA5YHTNYVVV4C3U252E2B6P6F5T3U6MM63WBSBZATAQI3EBTQ4'; +const PUBLIC_KEY = 'GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN7'; + +function trace(overrides: Partial = {}): RequestTrace { + return { + resource: 'GET /ledgers', + startedAt: '2026-01-01T00:00:00.000Z', + completedAt: '2026-01-01T00:00:00.100Z', + durationMs: 100, + httpStatus: 200, + success: true, + attempts: 1, + retried: false, + slow: false, + error: null, + ...overrides, + }; +} + +describe('Issue #232 / ISSUE-172: sanitization', () => { + it('redacts Stellar secret seeds', () => { + const sanitized = sanitizeDiagnostic(`failed with seed ${SECRET_SEED}`); + expect(sanitized).not.toContain(SECRET_SEED); + expect(sanitized).toContain('[REDACTED_SECRET_KEY]'); + }); + + it('keeps public keys intact so traces stay useful', () => { + expect(sanitizeDiagnostic(`account ${PUBLIC_KEY} not found`)).toContain(PUBLIC_KEY); + }); + + it('redacts JWTs', () => { + const jwt = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjMifQ.abc-DEF_123'; + const sanitized = sanitizeDiagnostic(`Authorization: Bearer ${jwt}`); + expect(sanitized).not.toContain(jwt); + expect(sanitized).toContain('[REDACTED_JWT]'); + }); + + it('redacts sensitive query parameters case-insensitively', () => { + const sanitized = sanitizeDiagnostic( + 'GET /accounts?token=abc123&API_KEY=xyz789&secret=hunter2&cursor=42', + ); + expect(sanitized).not.toContain('abc123'); + expect(sanitized).not.toContain('xyz789'); + expect(sanitized).not.toContain('hunter2'); + expect(sanitized).toContain('cursor=42'); + }); + + it('leaves ordinary diagnostics unchanged', () => { + expect(sanitizeDiagnostic('GET /ledgers?limit=1')).toBe('GET /ledgers?limit=1'); + }); +}); + +describe('Issue #232 / ISSUE-172: error inspection', () => { + it('extracts an HTTP status from Horizon error shapes', () => { + expect(extractHttpStatus({ response: { status: 404 } })).toBe(404); + expect(extractHttpStatus({ status: 429 })).toBe(429); + expect(extractHttpStatus(new Error('network down'))).toBeNull(); + expect(extractHttpStatus(null)).toBeNull(); + }); + + it('describes and sanitizes thrown values', () => { + expect(describeError(new Error('boom'))).toBe('boom'); + expect(describeError('plain string')).toBe('plain string'); + expect(describeError(new Error(`leaked ${SECRET_SEED}`))).toContain('[REDACTED_SECRET_KEY]'); + }); +}); + +describe('Issue #232 / ISSUE-172: latency aggregation', () => { + it('computes the median for odd and even sets', () => { + expect(median([30, 10, 20])).toBe(20); + expect(median([40, 10, 30, 20])).toBe(25); + expect(median([])).toBe(0); + }); + + it('aggregates min, max, average, and median latency', () => { + const stats = computeLatency([ + trace({ durationMs: 100 }), + trace({ durationMs: 300 }), + trace({ durationMs: 200 }), + ]); + expect(stats).toEqual({ count: 3, minMs: 100, maxMs: 300, averageMs: 200, medianMs: 200 }); + }); + + it('includes failed requests in latency statistics', () => { + const stats = computeLatency([ + trace({ durationMs: 50 }), + trace({ durationMs: 5000, success: false }), + ]); + expect(stats.count).toBe(2); + expect(stats.maxMs).toBe(5000); + }); + + it('returns zeroed statistics with no traces', () => { + expect(computeLatency([])).toEqual({ + count: 0, + minMs: 0, + maxMs: 0, + averageMs: 0, + medianMs: 0, + }); + }); +}); + +describe('Issue #232 / ISSUE-172: diagnostic summary', () => { + it('counts successes, failures, retries, attempts, and slow requests', () => { + const summary = summarizeTraces( + [ + trace(), + trace({ success: false, httpStatus: 404, error: 'not found', attempts: 2, retried: true }), + trace({ durationMs: 2500, slow: true }), + ], + { horizonUrl: 'https://horizon-testnet.stellar.org', slowThresholdMs: 1000, timeoutMs: 5000 }, + ); + expect(summary.totalRequests).toBe(3); + expect(summary.successfulRequests).toBe(2); + expect(summary.failedRequests).toBe(1); + expect(summary.retriedRequests).toBe(1); + expect(summary.totalAttempts).toBe(4); + expect(summary.slowRequests).toBe(1); + }); + + it('produces structured JSON metrics', () => { + const parsed = JSON.parse( + JSON.stringify( + summarizeTraces([trace()], { + horizonUrl: 'https://horizon-testnet.stellar.org', + slowThresholdMs: 1000, + timeoutMs: 5000, + }), + ), + ); + expect(parsed.latency.count).toBe(1); + expect(parsed.traces[0].resource).toBe('GET /ledgers'); + }); +}); + +describe('Issue #232 / ISSUE-172: tracer', () => { + it('traces a successful request with timing and status', async () => { + const tracer = new HorizonRequestTracer(); + const result = await tracer.trace('GET /ledgers', async () => ({ records: [1] })); + + expect(result).toEqual({ records: [1] }); + const traces = tracer.getTraces(); + expect(traces).toHaveLength(1); + expect(traces[0].success).toBe(true); + expect(traces[0].httpStatus).toBe(200); + expect(traces[0].attempts).toBe(1); + expect(traces[0].durationMs).toBeGreaterThanOrEqual(0); + expect(Date.parse(traces[0].completedAt)).toBeGreaterThanOrEqual( + Date.parse(traces[0].startedAt), + ); + }); + + it('traces an HTTP error and preserves its status', async () => { + const tracer = new HorizonRequestTracer({ maxAttempts: 1 }); + const result = await tracer.trace('GET /accounts/x', async () => { + throw Object.assign(new Error('Not Found'), { response: { status: 404 } }); + }); + + expect(result).toBeNull(); + const [recorded] = tracer.getTraces(); + expect(recorded.success).toBe(false); + expect(recorded.httpStatus).toBe(404); + expect(recorded.error).toBe('Not Found'); + }); + + it('traces a network error with no HTTP status', async () => { + const tracer = new HorizonRequestTracer({ maxAttempts: 1 }); + await tracer.trace('GET /ledgers', async () => { + throw new Error('getaddrinfo ENOTFOUND horizon.invalid'); + }); + + const [recorded] = tracer.getTraces(); + expect(recorded.httpStatus).toBeNull(); + expect(recorded.error).toMatch(/ENOTFOUND/); + }); + + it('retries a failing request and records the attempt count', async () => { + const tracer = new HorizonRequestTracer({ maxAttempts: 3, retryBackoffMs: 1 }); + let calls = 0; + const result = await tracer.trace('GET /ledgers', async () => { + calls += 1; + if (calls < 3) throw new Error('transient'); + return 'ok'; + }); + + expect(result).toBe('ok'); + const [recorded] = tracer.getTraces(); + expect(recorded.attempts).toBe(3); + expect(recorded.retried).toBe(true); + expect(recorded.success).toBe(true); + }); + + it('records a single trace covering all exhausted attempts', async () => { + const tracer = new HorizonRequestTracer({ maxAttempts: 2, retryBackoffMs: 1 }); + await tracer.trace('GET /ledgers', async () => { + throw new Error('always fails'); + }); + + const traces = tracer.getTraces(); + expect(traces).toHaveLength(1); + expect(traces[0].attempts).toBe(2); + expect(traces[0].success).toBe(false); + }); + + it('times out a hanging request instead of waiting forever', async () => { + const tracer = new HorizonRequestTracer({ timeoutMs: 20, maxAttempts: 1 }); + const result = await tracer.trace( + 'GET /ledgers', + () => new Promise(() => undefined) as Promise, + ); + + expect(result).toBeNull(); + const [recorded] = tracer.getTraces(); + expect(recorded.success).toBe(false); + expect(recorded.error).toMatch(/timed out after 20ms/); + }); + + it('flags requests at or above the slow threshold', async () => { + const tracer = new HorizonRequestTracer({ slowRequestMs: 0 }); + await tracer.trace('GET /ledgers', async () => 'fast'); + expect(tracer.getTraces()[0].slow).toBe(true); + }); + + it('sanitizes the resource label', async () => { + const tracer = new HorizonRequestTracer(); + await tracer.trace('GET /accounts?token=supersecret', async () => 'ok'); + expect(tracer.getTraces()[0].resource).not.toContain('supersecret'); + }); + + it('summarizes everything traced so far', async () => { + const tracer = new HorizonRequestTracer({ maxAttempts: 1, timeoutMs: 500 }); + await tracer.trace('GET /ledgers', async () => 'ok'); + await tracer.trace('GET /fee_stats', async () => { + throw new Error('boom'); + }); + + const summary = tracer.summarize('https://horizon-testnet.stellar.org'); + expect(summary.totalRequests).toBe(2); + expect(summary.successfulRequests).toBe(1); + expect(summary.failedRequests).toBe(1); + expect(summary.timeoutMs).toBe(500); + expect(summary.latency.count).toBe(2); + }); + + it('returns a defensive copy of its traces', async () => { + const tracer = new HorizonRequestTracer(); + await tracer.trace('GET /ledgers', async () => 'ok'); + tracer.getTraces().push(trace()); + expect(tracer.getTraces()).toHaveLength(1); + }); + + it('rejects after the configured timeout', async () => { + await expect(timeoutAfter(5, 'GET /ledgers')).rejects.toThrow(/timed out after 5ms/); + }); +}); + +describe('Issue #232 / ISSUE-172: runner registration', () => { + it('registers the example with timeout, threshold, and verbose parameters', () => { + const entry = examples['172-horizon-request-tracing']; + expect(entry).toBeDefined(); + expect(typeof entry.run).toBe('function'); + expect(entry.params?.map((param) => param.name)).toEqual([ + 'timeoutMs', + 'slowRequestMs', + 'verbose', + ]); + }); +});