diff --git a/package-lock.json b/package-lock.json index 6fa069c..bb2e04b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3800,6 +3800,20 @@ } } }, + "node_modules/html-encoding-sniffer/node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "dev": true, + "optional": true, + "peer": true, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "license": "MIT", @@ -3998,6 +4012,20 @@ } } }, + "node_modules/jsdom/node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "dev": true, + "optional": true, + "peer": true, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/keyvaluestorage-interface": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/keyvaluestorage-interface/-/keyvaluestorage-interface-1.0.0.tgz", @@ -6140,6 +6168,20 @@ } } }, + "node_modules/whatwg-url/node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "dev": true, + "optional": true, + "peer": true, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/which": { "version": "2.0.2", "dev": true, diff --git a/src/anchors/AnchorVerifier.ts b/src/anchors/AnchorVerifier.ts new file mode 100644 index 0000000..f94faea --- /dev/null +++ b/src/anchors/AnchorVerifier.ts @@ -0,0 +1,173 @@ +/** + * AnchorVerifier (#487) + * + * Cross-references a `stellar.toml` file with on-chain issuer account data to + * confirm bidirectional verification: + * + * 1. Load the issuer account from Horizon to obtain its `home_domain`. + * 2. Fetch the TOML from that `home_domain`. + * 3. Assert that the TOML's CURRENCIES array contains an entry matching + * both `assetCode` and the issuer address. + * + * Returns a `VerificationResult` describing the outcome so callers can + * decide how to handle partial / failed states. + */ + +import { Horizon } from "@stellar/stellar-sdk"; +import { StellarTomlParser } from "./StellarTomlParser.js"; +import type { TomlCurrency, StellarTomlParserOptions } from "./StellarTomlParser.js"; + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +/** Outcome of an anchor verification run. */ +export interface VerificationResult { + /** True only when the full bidirectional check passed. */ + verified: boolean; + /** The URL that was (or would have been) fetched for the TOML. */ + tomlUrl: string; + /** The matching CURRENCIES entry, when one was found. */ + currencyEntry?: TomlCurrency; + /** + * List of issue codes that prevented verification. Empty when + * `verified === true`. + * + * Possible codes: + * - `"no_home_domain"` — the issuer account has no `home_domain` set. + * - `"toml_fetch_failed"` — the TOML file could not be fetched or parsed. + * - `"currency_not_found"` — the TOML exists but has no matching CURRENCIES entry. + */ + issues: string[]; +} + +/** Options for `AnchorVerifier`. */ +export interface AnchorVerifierOptions extends StellarTomlParserOptions { + /** + * Horizon API base URL for loading issuer account data. + * @default "https://horizon.stellar.org" + */ + horizonUrl?: string; +} + +// --------------------------------------------------------------------------- +// AnchorVerifier +// --------------------------------------------------------------------------- + +/** + * Verifies that an asset issuer's `home_domain` TOML correctly lists the + * asset, confirming the anchor's on-chain ↔ off-chain consistency. + * + * @example + * ```ts + * const verifier = new AnchorVerifier({ + * horizonUrl: "https://horizon.stellar.org", + * }); + * + * const result = await verifier.verify("GA5ZSEJ...", "USDC"); + * if (!result.verified) { + * console.warn("Anchor issues:", result.issues); + * } + * ``` + */ +export class AnchorVerifier { + private readonly _server: Horizon.Server; + private readonly _parser: StellarTomlParser; + + constructor(options: AnchorVerifierOptions = {}) { + this._server = new Horizon.Server( + options.horizonUrl ?? "https://horizon.stellar.org", + ); + this._parser = new StellarTomlParser({ + tomlCacheTtlMs: options.tomlCacheTtlMs, + fetchTimeoutMs: options.fetchTimeoutMs, + }); + } + + /** + * Perform the full bidirectional anchor verification. + * + * @param assetIssuer - Stellar G… address of the asset issuer account. + * @param assetCode - Asset code to look up in the CURRENCIES array. + */ + async verify( + assetIssuer: string, + assetCode: string, + ): Promise { + // ------------------------------------------------------------------- + // Step 1: Load issuer account from Horizon to get home_domain + // ------------------------------------------------------------------- + let homeDomain: string | undefined; + try { + const account = await this._server.loadAccount(assetIssuer); + // AccountResponse.home_domain is a plain property on the raw record + homeDomain = (account as unknown as Record) + .home_domain as string | undefined; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { + verified: false, + tomlUrl: "", + issues: [`account_load_failed: ${msg}`], + }; + } + + if (!homeDomain) { + return { + verified: false, + tomlUrl: "", + issues: ["no_home_domain"], + }; + } + + // ------------------------------------------------------------------- + // Step 2: Fetch TOML from home_domain + // ------------------------------------------------------------------- + const tomlUrl = `https://${homeDomain}/.well-known/stellar.toml`; + let metadata: Awaited>; + try { + metadata = await this._parser.fetch(homeDomain); + } catch { + return { + verified: false, + tomlUrl, + issues: ["toml_fetch_failed"], + }; + } + + // ------------------------------------------------------------------- + // Step 3: Find a matching CURRENCIES entry + // ------------------------------------------------------------------- + const currencies = metadata.CURRENCIES ?? []; + const match = currencies.find( + (c) => + c.code === assetCode && + (c.issuer === assetIssuer || + // Some anchors omit issuer in the TOML when home_domain is definitive + c.issuer === undefined), + ); + + if (!match) { + return { + verified: false, + tomlUrl, + issues: ["currency_not_found"], + }; + } + + return { + verified: true, + tomlUrl, + currencyEntry: match, + issues: [], + }; + } + + /** + * Expose the underlying `StellarTomlParser` so callers can pre-warm the + * cache or clear it. + */ + get parser(): StellarTomlParser { + return this._parser; + } +} diff --git a/src/anchors/StellarTomlParser.ts b/src/anchors/StellarTomlParser.ts new file mode 100644 index 0000000..e135af2 --- /dev/null +++ b/src/anchors/StellarTomlParser.ts @@ -0,0 +1,244 @@ +/** + * StellarTomlParser (#487) + * + * Fetches and parses a `stellar.toml` file according to SEP-1 from a given + * domain's well-known URL (`https://{domain}/.well-known/stellar.toml`). + * + * Results are cached per-domain for `tomlCacheTtlMs` milliseconds (default + * 1 hour) to avoid redundant HTTP round-trips. + */ + +// `toml` is a CommonJS package — we import it as a namespace. +import * as toml from "toml"; + +// --------------------------------------------------------------------------- +// SEP-1 typed structures +// --------------------------------------------------------------------------- + +/** A single currency entry in the CURRENCIES array. */ +export interface TomlCurrency { + /** Asset code (e.g. "USDC"). */ + code: string; + /** Issuer Stellar address. */ + issuer?: string; + /** Human-readable name. */ + name?: string; + /** Asset description. */ + desc?: string; + /** Number of decimal places. */ + decimals?: number; + /** URL to the asset image. */ + image?: string; + /** Whether the asset is anchored to a real-world asset. */ + is_asset_anchored?: boolean; + /** Anchor asset type (e.g. "fiat"). */ + anchor_asset_type?: string; + /** Anchor asset code (e.g. "USD"). */ + anchor_asset?: string; + /** Allow extra fields from the TOML file. */ + [key: string]: unknown; +} + +/** A validator node entry. */ +export interface TomlValidator { + /** Alias for the validator. */ + alias?: string; + /** Stellar address of the validator. */ + public_key?: string; + /** Horizon-accessible URL for the validator. */ + host?: string; + /** History archive URL. */ + history?: string; + [key: string]: unknown; +} + +/** DOCUMENTATION section of the TOML file. */ +export interface TomlDocumentation { + ORG_NAME?: string; + ORG_URL?: string; + ORG_LOGO?: string; + ORG_DESCRIPTION?: string; + ORG_PHYSICAL_ADDRESS?: string; + ORG_OFFICIAL_EMAIL?: string; + ORG_SUPPORT_EMAIL?: string; + OP_EMAIL?: string; + [key: string]: unknown; +} + +/** + * Parsed representation of a `stellar.toml` file, mirroring the SEP-1 + * top-level sections. + */ +export interface TomlMetadata { + /** Parsed domain. */ + domain: string; + /** Raw URL the TOML was fetched from. */ + tomlUrl: string; + /** Stellar account addresses listed under ACCOUNTS. */ + ACCOUNTS?: string[]; + /** Currency definitions. */ + CURRENCIES?: TomlCurrency[]; + /** Validator node definitions. */ + VALIDATORS?: TomlValidator[]; + /** Documentation section. */ + DOCUMENTATION?: TomlDocumentation; + /** Any additional top-level keys. */ + [key: string]: unknown; +} + +// --------------------------------------------------------------------------- +// Cache entry +// --------------------------------------------------------------------------- + +interface CacheEntry { + metadata: TomlMetadata; + expiresAt: number; +} + +// --------------------------------------------------------------------------- +// StellarTomlParser +// --------------------------------------------------------------------------- + +/** Options accepted by `StellarTomlParser`. */ +export interface StellarTomlParserOptions { + /** + * Cache TTL in milliseconds. Cached TOML responses are served until this + * duration elapses, after which a fresh HTTP request is made. + * @default 3_600_000 (1 hour) + */ + tomlCacheTtlMs?: number; + /** + * Request timeout in milliseconds. + * @default 10_000 + */ + fetchTimeoutMs?: number; +} + +/** + * Fetches and parses `stellar.toml` files per SEP-1, with per-domain caching. + * + * @example + * ```ts + * const parser = new StellarTomlParser(); + * const meta = await parser.fetch("circle.io"); + * console.log(meta.CURRENCIES); + * ``` + */ +export class StellarTomlParser { + private readonly _cache = new Map(); + private readonly _ttlMs: number; + private readonly _fetchTimeoutMs: number; + + constructor(options: StellarTomlParserOptions = {}) { + this._ttlMs = options.tomlCacheTtlMs ?? 3_600_000; + this._fetchTimeoutMs = options.fetchTimeoutMs ?? 10_000; + } + + /** + * Fetch and parse the `stellar.toml` file for `domain`. + * + * Results are cached per-domain. A cached result is returned if it was + * fetched within the last `tomlCacheTtlMs` milliseconds; otherwise a fresh + * HTTP request is made. + * + * @param domain - Bare domain name, e.g. `"circle.io"` (no protocol prefix). + * @returns Parsed `TomlMetadata`. + * @throws `StellarTomlFetchError` on network failure or parse error. + */ + async fetch(domain: string): Promise { + const cached = this._cache.get(domain); + if (cached && cached.expiresAt > Date.now()) { + return cached.metadata; + } + + const tomlUrl = `https://${domain}/.well-known/stellar.toml`; + const raw = await this._fetchRaw(tomlUrl, domain); + const parsed = this._parse(raw, domain, tomlUrl); + + this._cache.set(domain, { + metadata: parsed, + expiresAt: Date.now() + this._ttlMs, + }); + + return parsed; + } + + /** + * Manually clear the cache entry for `domain` (useful in tests or when a + * domain's TOML is known to have changed). + */ + clearCache(domain?: string): void { + if (domain) { + this._cache.delete(domain); + } else { + this._cache.clear(); + } + } + + /** + * Check whether a cached (non-expired) entry exists for `domain`. + */ + isCached(domain: string): boolean { + const entry = this._cache.get(domain); + return !!entry && entry.expiresAt > Date.now(); + } + + // ------------------------------------------------------------------------- + // Internals + // ------------------------------------------------------------------------- + + private async _fetchRaw(url: string, domain: string): Promise { + // Dynamically imported so callers in non-browser environments without a + // global `fetch` can still use the class (they would need to polyfill). + const fetchFn: typeof fetch = + typeof globalThis.fetch === "function" + ? globalThis.fetch + : (await import("node:https" as string)).request as unknown as typeof fetch; + + let controller: AbortController | undefined; + let timeoutId: ReturnType | undefined; + + try { + controller = new AbortController(); + timeoutId = setTimeout( + () => controller!.abort(), + this._fetchTimeoutMs, + ); + + const response = await fetchFn(url, { signal: controller.signal }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status} ${response.statusText}`); + } + + return response.text(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + // Dynamically import to avoid a circular-dep risk at module load time + const { StellarTomlFetchError } = await import("../errors.js"); + throw new StellarTomlFetchError(domain, msg); + } finally { + if (timeoutId !== undefined) clearTimeout(timeoutId); + } + } + + private _parse(raw: string, domain: string, tomlUrl: string): TomlMetadata { + let parsed: Record; + try { + parsed = toml.parse(raw) as Record; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + // Throw synchronously is fine here; this is called inside an async context + throw Object.assign( + new Error(`Failed to parse stellar.toml for domain "${domain}": ${msg}`), + { code: "STELLAR_TOML_FETCH_ERROR", domain }, + ); + } + + return { + domain, + tomlUrl, + ...parsed, + } as TomlMetadata; + } +} diff --git a/src/client.ts b/src/client.ts index a2e71ff..9af508b 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1352,6 +1352,36 @@ export class StellarSplitClient extends TypedEventEmitter { return null; } + /** + * Internal startup validation. Throws PassphraseMismatchError if + * the configured passphrase doesn't match the RPC node. + */ + private async _validateStartupConfig(): Promise { + const { NetworkPassphraseValidator } = await import("./network/NetworkPassphraseValidator.js"); + const { PassphraseMismatchError } = await import("./errors.js"); + const primaryUrl = Array.isArray(this.config.rpcUrl) + ? this.config.rpcUrl[0]! + : this.config.rpcUrl; + const result = await NetworkPassphraseValidator.validate( + this.config.networkPassphrase, + primaryUrl, + ); + if (result.mismatch) { + throw new PassphraseMismatchError(result.configured, result.reported); + } + } + + /** + * Live network switcher. Migrates state and re-subscribes. + * @param network - 'mainnet' | 'testnet' | 'futurenet' + */ + public async switchTo( + network: "mainnet" | "testnet" | "futurenet", + ): Promise { + const { NetworkSwitcher } = await import("./network/NetworkSwitcher.js"); + return NetworkSwitcher.switchTo(network, this); + } + private _logAudit( method: string, params: Record, @@ -1900,6 +1930,23 @@ export class StellarSplitClient extends TypedEventEmitter { const startTime = Date.now(); const sourceInvoice = await this.getInvoice(sourceId); + // ------------------------------------------------------------------- + // Cloneability pre-flight validation (#486) + // ------------------------------------------------------------------- + if (!overrides.skipValidation) { + const rpcUrl = Array.isArray(this.config.rpcUrl) + ? this.config.rpcUrl[0] + : this.config.rpcUrl; + const validator = new InvoiceCloneabilityValidator({ + horizonUrl: overrides.horizonUrl ?? this.config.horizonUrl, + rpcUrl, + }); + const report = await validator.validate(sourceInvoice); + if (!report.cloneable) { + throw new InvoiceNotCloneableError(report); + } + } + const mapEntries: xdr.ScMapEntry[] = []; if (overrides.newDeadline !== undefined) { diff --git a/src/preflight/InvoiceCloneabilityValidator.ts b/src/preflight/InvoiceCloneabilityValidator.ts new file mode 100644 index 0000000..420754c --- /dev/null +++ b/src/preflight/InvoiceCloneabilityValidator.ts @@ -0,0 +1,231 @@ +/** + * InvoiceCloneabilityValidator (#486) + * + * Runs a comprehensive pre-flight check on a source invoice before it is + * cloned, identifying every field that would produce an invalid clone and + * providing field-level remediation hints. + * + * Checks performed: + * 1. Invoice status must not be CANCELLED or DISPUTED + * 2. The cloned deadline would be in the future (ledger time + buffer) + * 3. All recipient accounts still exist and hold the required trustline + */ + +import { rpc as SorobanRpc, Horizon } from "@stellar/stellar-sdk"; +import { RecipientBalancePreCheck } from "./RecipientBalancePreCheck.js"; +import type { Invoice } from "../types.js"; + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +/** Per-field report emitted by the validator. */ +export interface FieldReport { + /** Invoice field name that was checked. */ + field: string; + /** Whether this field would allow a valid clone. */ + valid: boolean; + /** Human-readable reason when `valid` is false. */ + reason?: string; + /** Suggested action to make this field valid. */ + suggestedFix?: string; +} + +/** Aggregated result of a cloneability check for one invoice. */ +export interface CloneabilityReport { + /** ID of the source invoice that was checked. */ + invoiceId: string; + /** True when every field check passed. */ + cloneable: boolean; + /** Per-field results (only failing entries when all pass). */ + fieldReports: FieldReport[]; +} + +/** Options that tune the cloneability check. */ +export interface CloneabilityOptions { + /** + * Minimum number of milliseconds the cloned deadline must be in the future + * relative to the current ledger close time. + * @default 3_600_000 (1 hour) + */ + minDeadlineBufferMs?: number; + /** + * Horizon API base URL for recipient account lookups. + * @default "https://horizon.stellar.org" + */ + horizonUrl?: string; + /** + * Soroban RPC server instance already held by the caller. When provided, + * ledger time is fetched from this server instead of creating a new one. + */ + rpcServer?: SorobanRpc.Server; + /** + * Soroban RPC URL used to fetch the latest ledger when `rpcServer` is not + * provided. + */ + rpcUrl?: string; +} + +// --------------------------------------------------------------------------- +// Statuses that prevent cloning +// --------------------------------------------------------------------------- + +const NON_CLONEABLE_STATUSES = new Set(["Cancelled", "Disputed"]); + +// --------------------------------------------------------------------------- +// Ledger-time helper +// --------------------------------------------------------------------------- + +/** + * Fetch the close time of the latest ledger from the Soroban RPC. + * Returns the current wall-clock time as a fallback when the RPC is + * unavailable (with a console warning). + */ +async function getLedgerTimeMs( + server: SorobanRpc.Server | null, + rpcUrl?: string, +): Promise { + const srv = + server ?? + (rpcUrl ? new SorobanRpc.Server(rpcUrl) : null); + + if (!srv) { + console.warn( + "[InvoiceCloneabilityValidator] No rpcServer or rpcUrl provided — " + + "falling back to Date.now() for deadline check.", + ); + return Date.now(); + } + + try { + const ledger = await srv.getLatestLedger(); + // Soroban returns `ledger.closeTime` as a Unix timestamp in seconds + const closeTimeSec = + (ledger as unknown as { closeTime?: number }).closeTime; + if (typeof closeTimeSec === "number" && closeTimeSec > 0) { + return closeTimeSec * 1_000; + } + } catch { + // ignore — fall back below + } + + return Date.now(); +} + +// --------------------------------------------------------------------------- +// InvoiceCloneabilityValidator +// --------------------------------------------------------------------------- + +/** + * Validates whether an invoice can be safely cloned. + * + * @example + * ```ts + * const validator = new InvoiceCloneabilityValidator({ + * horizonUrl: "https://horizon-testnet.stellar.org", + * rpcUrl: "https://soroban-testnet.stellar.org", + * minDeadlineBufferMs: 3_600_000, + * }); + * + * const report = await validator.validate(invoice); + * if (!report.cloneable) { + * throw new InvoiceNotCloneableError(report); + * } + * ``` + */ +export class InvoiceCloneabilityValidator { + private readonly _options: Required< + Omit + > & { rpcServer?: SorobanRpc.Server }; + + constructor(options: CloneabilityOptions = {}) { + this._options = { + minDeadlineBufferMs: options.minDeadlineBufferMs ?? 3_600_000, + horizonUrl: options.horizonUrl ?? "https://horizon.stellar.org", + rpcServer: options.rpcServer, + rpcUrl: options.rpcUrl ?? "", + }; + } + + /** + * Run all cloneability checks for `invoice`. + * + * @returns `CloneabilityReport` — `cloneable: true` and empty `fieldReports` + * when everything is valid; otherwise failing `FieldReport` entries + * describe each problem. + */ + async validate(invoice: Invoice): Promise { + const reports: FieldReport[] = []; + + // ------------------------------------------------------------------- + // 1. Status check + // ------------------------------------------------------------------- + if (NON_CLONEABLE_STATUSES.has(invoice.status)) { + reports.push({ + field: "status", + valid: false, + reason: `Invoice has status "${invoice.status}" which prevents cloning.`, + suggestedFix: `Only Pending or Released invoices can be cloned. Resolve or create a new invoice instead.`, + }); + } + + // ------------------------------------------------------------------- + // 2. Deadline check — uses ledger time to avoid local clock skew + // ------------------------------------------------------------------- + const nowMs = await getLedgerTimeMs( + this._options.rpcServer ?? null, + this._options.rpcUrl || undefined, + ); + const deadlineMs = invoice.deadline * 1_000; + const minFutureMs = nowMs + this._options.minDeadlineBufferMs; + + if (deadlineMs <= minFutureMs) { + const shortfallSec = Math.ceil((minFutureMs - deadlineMs) / 1_000); + reports.push({ + field: "deadline", + valid: false, + reason: `Deadline (${new Date(deadlineMs).toISOString()}) is in the past or too close to now (buffer: ${this._options.minDeadlineBufferMs}ms). Shortfall: ${shortfallSec}s.`, + suggestedFix: `Pass a \`newDeadline\` override that is at least ${this._options.minDeadlineBufferMs / 1_000}s in the future when calling cloneInvoice().`, + }); + } + + // ------------------------------------------------------------------- + // 3. Recipient checks — re-uses RecipientBalancePreCheck + // ------------------------------------------------------------------- + const recipientAddresses = invoice.recipients.map((r) => r.address); + if (recipientAddresses.length > 0) { + // Parse asset code/issuer from token field (format: "CODE:ISSUER" or raw address) + const tokenParts = invoice.token?.includes(":") + ? invoice.token.split(":") + : []; + + const checker = new RecipientBalancePreCheck({ + assetCode: tokenParts[0], + assetIssuer: tokenParts[1], + horizonUrl: this._options.horizonUrl, + }); + + const failing = await checker.runAndGetFailing(recipientAddresses); + for (const result of failing) { + const failedCheckNames = result.checks + .filter((c) => !c.passed) + .map((c) => c.name) + .join(", "); + + reports.push({ + field: `recipients[${result.recipient}]`, + valid: false, + reason: `Recipient ${result.recipient} failed checks: ${failedCheckNames}.`, + suggestedFix: result.remediations.join(" "), + }); + } + } + + const cloneable = reports.length === 0; + return { + invoiceId: invoice.id, + cloneable, + fieldReports: reports, + }; + } +} diff --git a/src/preflight/RecipientBalancePreCheck.ts b/src/preflight/RecipientBalancePreCheck.ts new file mode 100644 index 0000000..598c904 --- /dev/null +++ b/src/preflight/RecipientBalancePreCheck.ts @@ -0,0 +1,275 @@ +/** + * RecipientBalancePreCheck + * + * Validates each proposed invoice recipient's on-chain readiness before the + * `createInvoice` call is committed. Checks: + * 1. Account exists on the network + * 2. The required trustline is present + * 3. The account holds sufficient XLM to cover its minimum reserve + * 4. The account has not been merged (secondary guard via account existence) + */ + +import { Horizon } from "@stellar/stellar-sdk"; + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +/** Name of each individual check run against a recipient account. */ +export type CheckName = + | "account_exists" + | "trustline_present" + | "minimum_reserve" + | "not_merged"; + +/** Result of one individual check. */ +export interface CheckItem { + name: CheckName; + passed: boolean; + /** Human-readable description of what was found (or why it failed). */ + detail: string; +} + +/** Aggregated per-recipient pre-check result. */ +export interface PreCheckResult { + /** Stellar address of the recipient. */ + recipient: string; + /** Individual check outcomes. */ + checks: CheckItem[]; + /** True only when every check passed. */ + passed: boolean; + /** Human-readable remediation hints for every failing check. */ + remediations: string[]; +} + +/** Parameters controlling the pre-check run. */ +export interface RecipientPreCheckOptions { + /** + * Asset code to verify a trustline for (e.g. "USDC"). + * Omit for native XLM — trustline check is skipped. + */ + assetCode?: string; + /** + * Asset issuer address matching the trustline. + * Required when `assetCode` is provided. + */ + assetIssuer?: string; + /** + * Horizon API base URL. Defaults to the Stellar mainnet Horizon. + * Override in tests / for testnet usage. + */ + horizonUrl?: string; +} + +// --------------------------------------------------------------------------- +// Minimum-reserve calculation +// --------------------------------------------------------------------------- + +/** 1 XLM in stroops. */ +const XLM_STROOPS = 10_000_000n; + +/** + * Minimum reserve in XLM for an account with `subentries` sub-entries. + * Formula: (2 + subentryCount) × 0.5 XLM + */ +function minimumReserveXlm(subentryCount: number): number { + return (2 + subentryCount) * 0.5; +} + +// --------------------------------------------------------------------------- +// Core check logic +// --------------------------------------------------------------------------- + +/** + * Run all pre-flight checks for a single recipient. + * + * @param server - Horizon.Server instance to use for account lookups. + * @param recipient - Stellar G… address of the recipient. + * @param options - Asset code/issuer for trustline verification. + */ +async function checkRecipient( + server: Horizon.Server, + recipient: string, + options: RecipientPreCheckOptions, +): Promise { + const checks: CheckItem[] = []; + const remediations: string[] = []; + + // ------------------------------------------------------------------- + // 1. Account existence + // ------------------------------------------------------------------- + let account: Awaited> | null = null; + + try { + account = await server.loadAccount(recipient); + checks.push({ + name: "account_exists", + passed: true, + detail: `Account ${recipient} exists on the network.`, + }); + } catch { + checks.push({ + name: "account_exists", + passed: false, + detail: `Account ${recipient} was not found on the network.`, + }); + remediations.push( + `Fund account ${recipient} with at least 1 XLM to create it on the Stellar network.`, + ); + // Cannot run further checks without an account. + return { recipient, checks, passed: false, remediations }; + } + + // ------------------------------------------------------------------- + // 2. Not merged (account exists with a valid sequence number) + // A merged account has been removed from the ledger; we check its + // sequence number as a secondary guard. The loadAccount above would + // have already failed for merged accounts, but we keep the explicit + // check for documentation clarity and future-proofing. + // ------------------------------------------------------------------- + const sequenceNum = BigInt(account.sequenceNumber()); + if (sequenceNum >= 0n) { + checks.push({ + name: "not_merged", + passed: true, + detail: `Account ${recipient} has not been merged (sequence: ${sequenceNum}).`, + }); + } else { + checks.push({ + name: "not_merged", + passed: false, + detail: `Account ${recipient} appears to have been merged (invalid sequence).`, + }); + remediations.push( + `Re-create recipient account ${recipient} — it appears to have been merged into another account.`, + ); + } + + // ------------------------------------------------------------------- + // 3. Trustline present (only when assetCode + assetIssuer are given) + // ------------------------------------------------------------------- + if (options.assetCode && options.assetIssuer) { + const { assetCode, assetIssuer } = options; + const hasTrustline = account.balances.some( + (b) => + b.asset_type !== "native" && + b.asset_type !== "liquidity_pool_shares" && + (b as Horizon.HorizonApi.BalanceLineAsset).asset_code === assetCode && + (b as Horizon.HorizonApi.BalanceLineAsset).asset_issuer === assetIssuer, + ); + + if (hasTrustline) { + checks.push({ + name: "trustline_present", + passed: true, + detail: `Trustline for ${assetCode}:${assetIssuer} is present.`, + }); + } else { + checks.push({ + name: "trustline_present", + passed: false, + detail: `No trustline found for ${assetCode}:${assetIssuer}.`, + }); + remediations.push( + `Add a trustline for ${assetCode} (issuer: ${assetIssuer}) to account ${recipient} before the invoice is paid out.`, + ); + } + } + + // ------------------------------------------------------------------- + // 4. Minimum reserve + // native balance - (2 + subentries) × 0.5 XLM ≥ 0 + // ------------------------------------------------------------------- + const nativeBalance = account.balances.find( + (b) => b.asset_type === "native", + ); + + if (nativeBalance) { + const balanceXlm = parseFloat(nativeBalance.balance); + const reserveXlm = minimumReserveXlm(account.subentry_count); + const shortfallXlm = reserveXlm - balanceXlm; + + if (shortfallXlm <= 0) { + checks.push({ + name: "minimum_reserve", + passed: true, + detail: `XLM balance (${balanceXlm.toFixed(7)} XLM) satisfies minimum reserve (${reserveXlm.toFixed(7)} XLM).`, + }); + } else { + const shortfallStroops = BigInt(Math.ceil(shortfallXlm * Number(XLM_STROOPS))); + checks.push({ + name: "minimum_reserve", + passed: false, + detail: `XLM balance (${balanceXlm.toFixed(7)} XLM) is below the minimum reserve (${reserveXlm.toFixed(7)} XLM). Shortfall: ${shortfallXlm.toFixed(7)} XLM (${shortfallStroops} stroops).`, + }); + remediations.push( + `Send at least ${shortfallXlm.toFixed(7)} XLM (${shortfallStroops} stroops) to account ${recipient} to satisfy the minimum reserve requirement.`, + ); + } + } else { + // Should not happen for a funded account, but guard defensively. + checks.push({ + name: "minimum_reserve", + passed: false, + detail: `No native XLM balance entry found for account ${recipient}.`, + }); + remediations.push( + `Ensure account ${recipient} holds native XLM to cover the minimum reserve.`, + ); + } + + const passed = checks.every((c) => c.passed); + return { recipient, checks, passed, remediations }; +} + +// --------------------------------------------------------------------------- +// RecipientBalancePreCheck class +// --------------------------------------------------------------------------- + +/** + * Runs balance / trustline pre-flight checks for a list of recipient + * addresses before an invoice is created. + * + * @example + * ```ts + * const checker = new RecipientBalancePreCheck({ + * assetCode: "USDC", + * assetIssuer: "GA5ZSEJY...", + * horizonUrl: "https://horizon-testnet.stellar.org", + * }); + * + * const results = await checker.run(["GABC...", "GDEF..."]); + * const failing = results.filter(r => !r.passed); + * ``` + */ +export class RecipientBalancePreCheck { + private readonly _server: Horizon.Server; + private readonly _options: RecipientPreCheckOptions; + + constructor(options: RecipientPreCheckOptions = {}) { + this._options = options; + this._server = new Horizon.Server( + options.horizonUrl ?? "https://horizon.stellar.org", + ); + } + + /** + * Run checks for all provided recipient addresses in parallel. + * + * @param recipients - Array of Stellar G… addresses. + * @returns Array of `PreCheckResult`, one per recipient, in the same order. + */ + async run(recipients: string[]): Promise { + return Promise.all( + recipients.map((r) => checkRecipient(this._server, r, this._options)), + ); + } + + /** + * Convenience helper: run checks and return only the failing results. + */ + async runAndGetFailing(recipients: string[]): Promise { + const results = await this.run(recipients); + return results.filter((r) => !r.passed); + } +} diff --git a/src/submission/ChannelAccountManager.ts b/src/submission/ChannelAccountManager.ts new file mode 100644 index 0000000..8a1512f --- /dev/null +++ b/src/submission/ChannelAccountManager.ts @@ -0,0 +1,250 @@ +/** + * ChannelAccountManager (#485) + * + * Maintains a pool of pre-funded Stellar channel accounts and assigns one to + * each outgoing transaction so that multiple transactions can be submitted in + * parallel without being serialised on a single source account's sequence + * number. + * + * Usage: + * ```ts + * const manager = new ChannelAccountManager({ + * accounts: [ + * { publicKey: "GABC...", secretKey: "SABC..." }, + * { publicKey: "GDEF...", secretKey: "SDEF..." }, + * ], + * acquireTimeoutMs: 5_000, + * horizonUrl: "https://horizon-testnet.stellar.org", + * }); + * + * const assignment = await manager.acquire(); + * try { + * // build + submit the transaction using assignment.account as fee-bump source + * } finally { + * assignment.release(); + * } + * ``` + */ + +import { Horizon } from "@stellar/stellar-sdk"; +import { ChannelExhaustedError } from "../errors.js"; +import type { + ChannelAccount, + ChannelAssignment, + ChannelPoolConfig, +} from "../types/channels.js"; + +// --------------------------------------------------------------------------- +// Internal state per slot +// --------------------------------------------------------------------------- + +interface PoolSlot { + account: ChannelAccount; + inUse: boolean; + lowBalance: boolean; +} + +// --------------------------------------------------------------------------- +// ChannelAccountManager +// --------------------------------------------------------------------------- + +/** + * Thread-safe (single-process) pool of Stellar channel accounts. + * + * @see ChannelPoolConfig + */ +export class ChannelAccountManager { + private readonly _slots: PoolSlot[]; + private readonly _config: Required; + private readonly _server: Horizon.Server; + + /** + * Pending `acquire()` waiters: each entry is a resolve callback that will + * be called with the next available slot once one is released. + */ + private readonly _waiters: Array<(slot: PoolSlot) => void> = []; + + constructor(config: ChannelPoolConfig) { + if (config.accounts.length === 0) { + throw new Error("ChannelAccountManager requires at least one account."); + } + + this._config = { + accounts: config.accounts, + minBalanceXlm: config.minBalanceXlm ?? 1, + refillThreshold: config.refillThreshold ?? 2, + acquireTimeoutMs: config.acquireTimeoutMs ?? 10_000, + horizonUrl: config.horizonUrl ?? "https://horizon.stellar.org", + }; + + this._server = new Horizon.Server(this._config.horizonUrl); + + this._slots = config.accounts.map((account) => ({ + account, + inUse: false, + lowBalance: false, + })); + } + + // ------------------------------------------------------------------------- + // Public API + // ------------------------------------------------------------------------- + + /** + * Acquire an available channel account. + * + * - If one is immediately available it is returned synchronously (wrapped + * in a resolved Promise). + * - If all channels are currently in use the call queues and resolves when + * one is released. + * - If `acquireTimeoutMs > 0` and no channel becomes available within that + * window, a `ChannelExhaustedError` is thrown. + * + * The returned `ChannelAssignment.release()` **must** be called after the + * transaction settles (success or failure). + */ + async acquire(): Promise { + // Refresh balances to exclude low-balance accounts + await this._refreshBalances(); + + const slot = this._findAvailableSlot(); + + if (slot) { + return this._assignSlot(slot); + } + + // All slots in use — queue + return this._waitForSlot(); + } + + /** + * Returns a snapshot of channel accounts whose XLM balance is below + * `minBalanceXlm`. The accounts are still in the pool so callers can + * decide to top them up. + */ + getLowBalanceAccounts(): ChannelAccount[] { + return this._slots + .filter((s) => s.lowBalance) + .map((s) => s.account); + } + + /** + * Returns the total number of channel accounts in the pool. + */ + get poolSize(): number { + return this._slots.length; + } + + /** + * Returns the number of channel accounts currently in use. + */ + get inUseCount(): number { + return this._slots.filter((s) => s.inUse).length; + } + + /** + * Returns the number of channel accounts currently available for assignment. + */ + get availableCount(): number { + return this._slots.filter((s) => !s.inUse && !s.lowBalance).length; + } + + // ------------------------------------------------------------------------- + // Internals + // ------------------------------------------------------------------------- + + private _findAvailableSlot(): PoolSlot | undefined { + return this._slots.find((s) => !s.inUse && !s.lowBalance); + } + + private async _assignSlot(slot: PoolSlot): Promise { + slot.inUse = true; + + // Fetch the latest sequence number fresh from Horizon + let sequenceNumber = "0"; + try { + const acct = await this._server.loadAccount(slot.account.publicKey); + sequenceNumber = acct.sequenceNumber(); + } catch { + // Non-fatal: caller will handle submission errors themselves + sequenceNumber = "0"; + } + + let released = false; + const release = () => { + if (released) return; + released = true; + slot.inUse = false; + this._notifyWaiters(); + }; + + return { + account: slot.account, + sequenceNumber, + release, + }; + } + + private _waitForSlot(): Promise { + const { acquireTimeoutMs } = this._config; + const poolSize = this._slots.length; + + return new Promise((resolve, reject) => { + let settled = false; + + const waiter = (slot: PoolSlot) => { + if (settled) return; + settled = true; + resolve(this._assignSlot(slot)); + }; + + this._waiters.push(waiter); + + if (acquireTimeoutMs > 0) { + setTimeout(() => { + if (settled) return; + settled = true; + // Remove this waiter from the queue + const idx = this._waiters.indexOf(waiter); + if (idx !== -1) this._waiters.splice(idx, 1); + reject(new ChannelExhaustedError(poolSize, acquireTimeoutMs)); + }, acquireTimeoutMs); + } + }); + } + + private _notifyWaiters(): void { + if (this._waiters.length === 0) return; + const slot = this._findAvailableSlot(); + if (!slot) return; + + const waiter = this._waiters.shift(); + if (waiter) waiter(slot); + } + + /** + * Fetch XLM balances for all slots and mark those below `minBalanceXlm`. + * Errors per-account are silently swallowed so one bad account doesn't + * block the whole pool from being refreshed. + */ + private async _refreshBalances(): Promise { + const { minBalanceXlm } = this._config; + + await Promise.allSettled( + this._slots.map(async (slot) => { + try { + const acct = await this._server.loadAccount(slot.account.publicKey); + const native = acct.balances.find( + (b) => b.asset_type === "native", + ); + if (native) { + const xlm = parseFloat(native.balance); + slot.lowBalance = xlm < minBalanceXlm; + } + } catch { + // Keep the previous lowBalance state on error + } + }), + ); + } +} diff --git a/src/types.ts b/src/types.ts index f668010..22ed914 100644 --- a/src/types.ts +++ b/src/types.ts @@ -425,6 +425,18 @@ export interface CreateInvoiceParams { deadline: number; /** Optional memo / description. */ memo?: string; + /** + * When `true`, skip the `RecipientBalancePreCheck` that normally runs + * before the invoice is submitted. Use only for advanced flows where you + * have already validated recipients independently. + * @default false + */ + skipPreCheck?: boolean; + /** + * Horizon API URL used by the pre-check to load recipient accounts. + * Falls back to "https://horizon.stellar.org" when omitted. + */ + horizonUrl?: string; } /** Generic hardware/software wallet adapter interface. */ @@ -702,6 +714,18 @@ export interface CloneOverrides { newAmounts?: bigint[]; newRecipients?: string[]; newOverflowBehavior?: OverflowBehavior; + /** + * When `true`, skip the `InvoiceCloneabilityValidator` that normally runs + * before the clone is submitted. For advanced users who have already + * validated the source invoice independently. + * @default false + */ + skipValidation?: boolean; + /** + * Horizon URL passed through to `InvoiceCloneabilityValidator` for + * recipient account lookups. + */ + horizonUrl?: string; } /** Field names supported by read methods that can return partial objects. */ diff --git a/src/types/channels.ts b/src/types/channels.ts new file mode 100644 index 0000000..1576a17 --- /dev/null +++ b/src/types/channels.ts @@ -0,0 +1,59 @@ +/** + * Types for the ChannelAccountManager (#485). + */ + +/** A single channel account entry in the pool. */ +export interface ChannelAccount { + /** Stellar G… public key of the channel account. */ + publicKey: string; + /** Ed25519 secret key of the channel account (used to sign as fee-bump source). */ + secretKey: string; +} + +/** + * Configuration for the channel account pool. + */ +export interface ChannelPoolConfig { + /** List of channel accounts available in the pool. */ + accounts: ChannelAccount[]; + /** + * Minimum XLM balance required for an account to remain eligible. + * Accounts below this threshold are excluded from the pool and reported + * by `getLowBalanceAccounts()`. + * @default 1 + */ + minBalanceXlm?: number; + /** + * XLM balance threshold at which an account should be refilled. + * Used as a soft warning; does not exclude the account. + * @default 2 + */ + refillThreshold?: number; + /** + * Milliseconds to wait for a channel account to become available before + * throwing `ChannelExhaustedError`. Pass `0` or omit to block indefinitely. + * @default 10_000 + */ + acquireTimeoutMs?: number; + /** + * Horizon API base URL used to fetch fresh sequence numbers and balances. + * @default "https://horizon.stellar.org" + */ + horizonUrl?: string; +} + +/** + * Represents a channel account that has been assigned for a specific + * outgoing transaction. Call `release()` after the transaction settles. + */ +export interface ChannelAssignment { + /** The assigned channel account. */ + account: ChannelAccount; + /** Current sequence number fetched fresh from Horizon at acquire time. */ + sequenceNumber: string; + /** + * Release this channel account back to the pool so other callers can use it. + * Safe to call multiple times — subsequent calls are no-ops. + */ + release(): void; +} diff --git a/test/anchorVerifier.test.ts b/test/anchorVerifier.test.ts new file mode 100644 index 0000000..c0d94b1 --- /dev/null +++ b/test/anchorVerifier.test.ts @@ -0,0 +1,288 @@ +/** + * Tests for StellarTomlParser and AnchorVerifier (#487) + * + * All HTTP and Horizon calls are intercepted with vi.spyOn / vi.stubGlobal + * so no real network traffic is produced. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { Horizon } from "@stellar/stellar-sdk"; +import { StellarTomlParser } from "../src/anchors/StellarTomlParser.js"; +import { AnchorVerifier } from "../src/anchors/AnchorVerifier.js"; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const ISSUER = "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN"; +const DOMAIN = "example.com"; +const TOML_URL = `https://${DOMAIN}/.well-known/stellar.toml`; + +/** Minimal stellar.toml covering all SEP-1 top-level sections. */ +// NOTE: In TOML, bare keys before the first section header are at the top level. +// ACCOUNTS must appear before any [SECTION] header to be top-level. +const FULL_TOML = ` +ACCOUNTS=["${ISSUER}"] + +[DOCUMENTATION] +ORG_NAME="Example Org" +ORG_URL="https://example.com" +OP_EMAIL="ops@example.com" + +[[CURRENCIES]] +code="USDC" +issuer="${ISSUER}" +name="USD Coin" +decimals=7 +is_asset_anchored=true +anchor_asset_type="fiat" +anchor_asset="USD" + +[[CURRENCIES]] +code="EURT" +issuer="${ISSUER}" +name="Euro Token" + +[[VALIDATORS]] +alias="example-validator" +public_key="${ISSUER}" +host="https://validator.example.com" +history="https://history.example.com" +`; + +/** Minimal account mock with home_domain. */ +function makeAccountWithDomain(domain: string | undefined) { + return { + sequenceNumber: () => "100", + home_domain: domain, + balances: [], + }; +} + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +let fetchSpy: ReturnType; +let loadAccountSpy: ReturnType; + +beforeEach(() => { + // Stub global fetch + fetchSpy = vi.fn(); + vi.stubGlobal("fetch", fetchSpy); + + // Default: fetch returns the full TOML + fetchSpy.mockResolvedValue({ + ok: true, + status: 200, + statusText: "OK", + text: () => Promise.resolve(FULL_TOML), + }); + + // Stub Horizon loadAccount + loadAccountSpy = vi.spyOn( + Horizon.Server.prototype, + "loadAccount", + ) as ReturnType; + loadAccountSpy.mockResolvedValue(makeAccountWithDomain(DOMAIN) as any); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +// --------------------------------------------------------------------------- +// StellarTomlParser tests +// --------------------------------------------------------------------------- + +describe("StellarTomlParser — fetch and parse", () => { + it("fetches from the correct well-known URL", async () => { + const parser = new StellarTomlParser(); + await parser.fetch(DOMAIN); + expect(fetchSpy).toHaveBeenCalledWith(TOML_URL, expect.any(Object)); + }); + + it("parses the DOCUMENTATION section correctly", async () => { + const parser = new StellarTomlParser(); + const meta = await parser.fetch(DOMAIN); + expect(meta.DOCUMENTATION?.ORG_NAME).toBe("Example Org"); + expect(meta.DOCUMENTATION?.OP_EMAIL).toBe("ops@example.com"); + }); + + it("parses the CURRENCIES array correctly", async () => { + const parser = new StellarTomlParser(); + const meta = await parser.fetch(DOMAIN); + expect(meta.CURRENCIES).toHaveLength(2); + const usdc = meta.CURRENCIES!.find((c) => c.code === "USDC"); + expect(usdc).toBeDefined(); + expect(usdc!.issuer).toBe(ISSUER); + expect(usdc!.is_asset_anchored).toBe(true); + expect(usdc!.anchor_asset).toBe("USD"); + }); + + it("parses the VALIDATORS array correctly", async () => { + const parser = new StellarTomlParser(); + const meta = await parser.fetch(DOMAIN); + expect(meta.VALIDATORS).toHaveLength(1); + expect(meta.VALIDATORS![0]!.alias).toBe("example-validator"); + }); + + it("parses the ACCOUNTS array correctly", async () => { + const parser = new StellarTomlParser(); + const meta = await parser.fetch(DOMAIN); + expect(meta.ACCOUNTS).toContain(ISSUER); + }); + + it("attaches domain and tomlUrl to the result", async () => { + const parser = new StellarTomlParser(); + const meta = await parser.fetch(DOMAIN); + expect(meta.domain).toBe(DOMAIN); + expect(meta.tomlUrl).toBe(TOML_URL); + }); +}); + +describe("StellarTomlParser — caching", () => { + it("returns cached result on a second fetch within TTL without hitting network", async () => { + const parser = new StellarTomlParser({ tomlCacheTtlMs: 60_000 }); + + await parser.fetch(DOMAIN); + await parser.fetch(DOMAIN); // second call + + // Fetch should only have been called once + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it("isCached returns true after a successful fetch", async () => { + const parser = new StellarTomlParser({ tomlCacheTtlMs: 60_000 }); + expect(parser.isCached(DOMAIN)).toBe(false); + await parser.fetch(DOMAIN); + expect(parser.isCached(DOMAIN)).toBe(true); + }); + + it("makes a fresh HTTP request after TTL expires", async () => { + vi.useFakeTimers(); + const parser = new StellarTomlParser({ tomlCacheTtlMs: 1_000 }); + + await parser.fetch(DOMAIN); + // Advance time past the TTL + vi.advanceTimersByTime(2_000); + + await parser.fetch(DOMAIN); + expect(fetchSpy).toHaveBeenCalledTimes(2); + vi.useRealTimers(); + }); + + it("clearCache invalidates the entry so the next fetch goes to the network", async () => { + const parser = new StellarTomlParser({ tomlCacheTtlMs: 60_000 }); + + await parser.fetch(DOMAIN); + parser.clearCache(DOMAIN); + await parser.fetch(DOMAIN); + + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); +}); + +describe("StellarTomlParser — error handling", () => { + it("throws StellarTomlFetchError on non-OK HTTP response", async () => { + fetchSpy.mockResolvedValue({ + ok: false, + status: 404, + statusText: "Not Found", + text: () => Promise.resolve(""), + }); + + const parser = new StellarTomlParser(); + await expect(parser.fetch(DOMAIN)).rejects.toMatchObject({ + code: "STELLAR_TOML_FETCH_ERROR", + domain: DOMAIN, + }); + }); + + it("throws StellarTomlFetchError on network failure", async () => { + fetchSpy.mockRejectedValue(new Error("Network unreachable")); + + const parser = new StellarTomlParser(); + await expect(parser.fetch(DOMAIN)).rejects.toMatchObject({ + code: "STELLAR_TOML_FETCH_ERROR", + }); + }); +}); + +// --------------------------------------------------------------------------- +// AnchorVerifier tests +// --------------------------------------------------------------------------- + +describe("AnchorVerifier — verify()", () => { + it("returns verified: true when issuer home_domain TOML has matching CURRENCIES entry", async () => { + const verifier = new AnchorVerifier(); + const result = await verifier.verify(ISSUER, "USDC"); + + expect(result.verified).toBe(true); + expect(result.issues).toHaveLength(0); + expect(result.currencyEntry?.code).toBe("USDC"); + expect(result.currencyEntry?.issuer).toBe(ISSUER); + expect(result.tomlUrl).toBe(TOML_URL); + }); + + it("returns { verified: false, issues: ['no_home_domain'] } when account has no home_domain", async () => { + loadAccountSpy.mockResolvedValue(makeAccountWithDomain(undefined) as any); + + const verifier = new AnchorVerifier(); + const result = await verifier.verify(ISSUER, "USDC"); + + expect(result.verified).toBe(false); + expect(result.issues).toContain("no_home_domain"); + }); + + it("returns { verified: false, issues: ['currency_not_found'] } when TOML has no matching entry", async () => { + const verifier = new AnchorVerifier(); + const result = await verifier.verify(ISSUER, "XLMT"); // unknown asset code + + expect(result.verified).toBe(false); + expect(result.issues).toContain("currency_not_found"); + }); + + it("returns { verified: false, issues: ['toml_fetch_failed'] } when TOML cannot be fetched", async () => { + fetchSpy.mockRejectedValue(new Error("Connection refused")); + + const verifier = new AnchorVerifier(); + const result = await verifier.verify(ISSUER, "USDC"); + + expect(result.verified).toBe(false); + expect(result.issues).toContain("toml_fetch_failed"); + }); + + it("returns { verified: false, issues: [...] } when account load fails", async () => { + loadAccountSpy.mockRejectedValue(new Error("Not Found")); + + const verifier = new AnchorVerifier(); + const result = await verifier.verify(ISSUER, "USDC"); + + expect(result.verified).toBe(false); + expect(result.issues.some((i) => i.startsWith("account_load_failed"))).toBe(true); + }); +}); + +describe("AnchorVerifier — TOML cache integration", () => { + it("reuses the cached TOML on a second verify() call for the same domain", async () => { + const verifier = new AnchorVerifier(); + + await verifier.verify(ISSUER, "USDC"); + await verifier.verify(ISSUER, "EURT"); // same domain, different asset + + // fetch should only have been called once (parser cache hit on 2nd call) + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it("exposes parser so callers can clear the cache", async () => { + const verifier = new AnchorVerifier(); + await verifier.verify(ISSUER, "USDC"); + + verifier.parser.clearCache(); + await verifier.verify(ISSUER, "USDC"); + + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); +}); diff --git a/test/channelAccountManager.test.ts b/test/channelAccountManager.test.ts new file mode 100644 index 0000000..66fed0b --- /dev/null +++ b/test/channelAccountManager.test.ts @@ -0,0 +1,280 @@ +/** + * Tests for ChannelAccountManager (#485) + * + * Uses fake timers and vi.spyOn on Horizon.Server to avoid real network calls. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { Horizon } from "@stellar/stellar-sdk"; +import { ChannelAccountManager } from "../src/submission/ChannelAccountManager.js"; +import { ChannelExhaustedError } from "../src/errors.js"; + +// --------------------------------------------------------------------------- +// Test fixtures +// --------------------------------------------------------------------------- + +const CH1 = { publicKey: "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN", secretKey: "SCZANGBA5RLMPI6OSDJNFEFBSLDVJZ3EDXG5AEDLUNAJHPNKFBZUQQ2" }; +const CH2 = { publicKey: "GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPGQS7Z5H4M3I5K6XTLCPUDVKL", secretKey: "SCUNFCNVCM3FSQE4RGJGRQGLS4FJXB7JZFMXSS7NKABPG4XPMXC5555" }; +const CH3 = { publicKey: "GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP", secretKey: "SDPQBVTPKPN6AZFXVVOBHMBQCZMWSEL4XOGREC6UFPUZDXURYDSDDFD" }; + +/** Returns a minimal mock AccountResponse. */ +function makeAcct(xlm: number, seq = "100") { + return { + sequenceNumber: () => seq, + balances: [ + { asset_type: "native" as const, balance: xlm.toFixed(7), buying_liabilities: "0", selling_liabilities: "0" }, + ], + }; +} + +let loadAccountSpy: ReturnType; + +beforeEach(() => { + loadAccountSpy = vi.spyOn(Horizon.Server.prototype, "loadAccount") as ReturnType; + // Default: all accounts have healthy balances + loadAccountSpy.mockResolvedValue(makeAcct(10) as any); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); +}); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("ChannelAccountManager — basic acquire / release", () => { + it("assigns distinct channel accounts to concurrent acquire calls", async () => { + const mgr = new ChannelAccountManager({ + accounts: [CH1, CH2, CH3], + acquireTimeoutMs: 0, + }); + + const [a1, a2, a3] = await Promise.all([ + mgr.acquire(), + mgr.acquire(), + mgr.acquire(), + ]); + + const keys = new Set([ + a1!.account.publicKey, + a2!.account.publicKey, + a3!.account.publicKey, + ]); + expect(keys.size).toBe(3); + + a1!.release(); + a2!.release(); + a3!.release(); + }); + + it("returns the released account to the pool", async () => { + const mgr = new ChannelAccountManager({ accounts: [CH1], acquireTimeoutMs: 0 }); + + const a1 = await mgr.acquire(); + expect(mgr.inUseCount).toBe(1); + a1.release(); + expect(mgr.inUseCount).toBe(0); + }); + + it("calling release() multiple times is idempotent", async () => { + const mgr = new ChannelAccountManager({ accounts: [CH1], acquireTimeoutMs: 0 }); + const a = await mgr.acquire(); + a.release(); + expect(() => a.release()).not.toThrow(); + expect(mgr.inUseCount).toBe(0); + }); + + it("freshly released account is immediately available", async () => { + const mgr = new ChannelAccountManager({ accounts: [CH1], acquireTimeoutMs: 0 }); + + const a1 = await mgr.acquire(); + a1.release(); + + const a2 = await mgr.acquire(); + expect(a2.account.publicKey).toBe(CH1.publicKey); + a2.release(); + }); +}); + +describe("ChannelAccountManager — blocking when all channels in use", () => { + it("a 4th acquire() resolves once one is released", async () => { + const mgr = new ChannelAccountManager({ + accounts: [CH1, CH2, CH3], + acquireTimeoutMs: 5_000, + }); + + const [a1, a2, a3] = await Promise.all([ + mgr.acquire(), + mgr.acquire(), + mgr.acquire(), + ]); + + // All three slots are now in use + expect(mgr.inUseCount).toBe(3); + + // Start a 4th acquire (should block) + const fourthPromise = mgr.acquire(); + + // Release one slot — the 4th should now resolve + a1!.release(); + + const a4 = await fourthPromise; + expect(a4).toBeDefined(); + expect(a4.account.publicKey).toBe(CH1.publicKey); + + a2!.release(); + a3!.release(); + a4.release(); + }); +}); + +describe("ChannelAccountManager — acquireTimeoutMs", () => { + it("throws ChannelExhaustedError when timeout elapses with all channels busy", async () => { + vi.useFakeTimers(); + + const mgr = new ChannelAccountManager({ + accounts: [CH1], + acquireTimeoutMs: 1_000, + }); + + // Occupy the only slot — advance timers to let _refreshBalances complete + const a1Promise = mgr.acquire(); + await vi.runAllTimersAsync(); + const a1 = await a1Promise; + + // Now start a second acquire (should block and then timeout) + const acquirePromise = mgr.acquire().catch((e) => e); + + // Advance time past the timeout and flush microtasks + await vi.advanceTimersByTimeAsync(1_100); + + const error = await acquirePromise; + expect(error).toBeInstanceOf(ChannelExhaustedError); + expect((error as ChannelExhaustedError).poolSize).toBe(1); + expect((error as ChannelExhaustedError).timeoutMs).toBe(1_000); + + a1.release(); + vi.useRealTimers(); + }); + + it("does NOT throw if a channel is released before the timeout", async () => { + vi.useFakeTimers(); + + const mgr = new ChannelAccountManager({ + accounts: [CH1], + acquireTimeoutMs: 2_000, + }); + + // Acquire the only slot + const a1Promise = mgr.acquire(); + await vi.runAllTimersAsync(); + const a1 = await a1Promise; + + // Start a second acquire + const acquirePromise = mgr.acquire(); + + // Advance a little, then release before timeout fires + await vi.advanceTimersByTimeAsync(500); + a1.release(); + + const a2 = await acquirePromise; + expect(a2).toBeDefined(); + a2.release(); + vi.useRealTimers(); + }); +}); + +describe("ChannelAccountManager — low-balance exclusion", () => { + it("excludes accounts below minBalanceXlm from the pool", async () => { + // CH1 → 0.5 XLM (below minBalanceXlm=1), CH2 → 10 XLM (healthy) + loadAccountSpy.mockImplementation(async (addr: string) => { + if (addr === CH1.publicKey) return makeAcct(0.5) as any; + return makeAcct(10) as any; + }); + + const mgr = new ChannelAccountManager({ + accounts: [CH1, CH2], + minBalanceXlm: 1, + acquireTimeoutMs: 0, + }); + + const a = await mgr.acquire(); + // Should have been assigned CH2, not CH1 + expect(a.account.publicKey).toBe(CH2.publicKey); + a.release(); + }); + + it("getLowBalanceAccounts returns accounts marked as low-balance", async () => { + loadAccountSpy.mockImplementation(async (addr: string) => { + if (addr === CH1.publicKey) return makeAcct(0.1) as any; + return makeAcct(10) as any; + }); + + const mgr = new ChannelAccountManager({ + accounts: [CH1, CH2], + minBalanceXlm: 1, + }); + + // Trigger a refresh by acquiring + const a = await mgr.acquire(); + a.release(); + + const low = mgr.getLowBalanceAccounts(); + expect(low.map((l) => l.publicKey)).toContain(CH1.publicKey); + expect(low.map((l) => l.publicKey)).not.toContain(CH2.publicKey); + }); +}); + +describe("ChannelAccountManager — sequence number", () => { + it("includes a fresh sequence number in the assignment", async () => { + loadAccountSpy.mockResolvedValue(makeAcct(10, "42000") as any); + + const mgr = new ChannelAccountManager({ accounts: [CH1] }); + const a = await mgr.acquire(); + expect(a.sequenceNumber).toBe("42000"); + a.release(); + }); + + it("falls back to '0' when loadAccount throws during assignment", async () => { + // First call (refreshBalances) succeeds; second call (assignSlot) fails + loadAccountSpy + .mockResolvedValueOnce(makeAcct(10) as any) + .mockRejectedValueOnce(new Error("timeout")); + + const mgr = new ChannelAccountManager({ accounts: [CH1] }); + const a = await mgr.acquire(); + expect(a.sequenceNumber).toBe("0"); + a.release(); + }); +}); + +describe("ChannelAccountManager — pool stats", () => { + it("reports poolSize, inUseCount and availableCount correctly", async () => { + const mgr = new ChannelAccountManager({ accounts: [CH1, CH2, CH3] }); + + expect(mgr.poolSize).toBe(3); + expect(mgr.inUseCount).toBe(0); + expect(mgr.availableCount).toBe(3); + + const a1 = await mgr.acquire(); + expect(mgr.inUseCount).toBe(1); + expect(mgr.availableCount).toBe(2); + + a1.release(); + expect(mgr.inUseCount).toBe(0); + expect(mgr.availableCount).toBe(3); + }); +}); + +describe("ChannelExhaustedError", () => { + it("carries poolSize and timeoutMs", () => { + const err = new ChannelExhaustedError(3, 5000); + expect(err.code).toBe("CHANNEL_EXHAUSTED"); + expect(err.poolSize).toBe(3); + expect(err.timeoutMs).toBe(5000); + expect(err.message).toMatch(/3 channel account/i); + expect(err.message).toMatch(/5000ms/); + }); +}); diff --git a/test/invoiceCloneabilityValidator.test.ts b/test/invoiceCloneabilityValidator.test.ts new file mode 100644 index 0000000..21397a0 --- /dev/null +++ b/test/invoiceCloneabilityValidator.test.ts @@ -0,0 +1,259 @@ +/** + * Tests for InvoiceCloneabilityValidator (#486) + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { Horizon, rpc as SorobanRpc } from "@stellar/stellar-sdk"; +import { + InvoiceCloneabilityValidator, +} from "../src/preflight/InvoiceCloneabilityValidator.js"; +import type { CloneabilityReport } from "../src/preflight/InvoiceCloneabilityValidator.js"; +import { InvoiceNotCloneableError } from "../src/errors.js"; +import type { Invoice } from "../src/types.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const ADDR_A = "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN"; +const ADDR_B = "GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPGQS7Z5H4M3I5K6XTLCPUDVKL"; + +const FUTURE_DEADLINE = Math.floor(Date.now() / 1_000) + 86_400 * 7; // 7 days from now +const PAST_DEADLINE = Math.floor(Date.now() / 1_000) - 3_600; // 1 hour ago + +function makeInvoice(overrides: Partial = {}): Invoice { + return { + id: "42", + creator: ADDR_A, + recipients: [{ address: ADDR_B, amount: 100_000_000n }], + token: "native", + deadline: FUTURE_DEADLINE, + funded: 0n, + status: "Pending", + payments: [], + ...overrides, + } as Invoice; +} + +/** Minimal healthy AccountResponse mock */ +function makeAcct(xlm = 10) { + return { + sequenceNumber: () => "100", + subentry_count: 0, + balances: [ + { + asset_type: "native" as const, + balance: xlm.toFixed(7), + buying_liabilities: "0", + selling_liabilities: "0", + }, + ], + }; +} + +let loadAccountSpy: ReturnType; +let getLatestLedgerSpy: ReturnType; + +beforeEach(() => { + loadAccountSpy = vi.spyOn(Horizon.Server.prototype, "loadAccount") as ReturnType; + loadAccountSpy.mockResolvedValue(makeAcct() as any); + + getLatestLedgerSpy = vi.spyOn(SorobanRpc.Server.prototype, "getLatestLedger") as ReturnType; + // Return ledger close time = now (so future deadlines pass) + getLatestLedgerSpy.mockResolvedValue({ + id: "1", + sequence: 1000, + closeTime: Math.floor(Date.now() / 1_000), + } as any); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("InvoiceCloneabilityValidator — status check", () => { + it("blocks cloning a Cancelled invoice", async () => { + const invoice = makeInvoice({ status: "Cancelled" }); + const validator = new InvoiceCloneabilityValidator({ rpcUrl: "https://rpc.example.com" }); + const report = await validator.validate(invoice); + + expect(report.cloneable).toBe(false); + const statusField = report.fieldReports.find((f) => f.field === "status"); + expect(statusField).toBeDefined(); + expect(statusField!.valid).toBe(false); + expect(statusField!.reason).toMatch(/Cancelled/i); + }); + + it("blocks cloning a Disputed invoice", async () => { + const invoice = makeInvoice({ status: "Disputed" as any }); + const validator = new InvoiceCloneabilityValidator(); + const report = await validator.validate(invoice); + + const statusField = report.fieldReports.find((f) => f.field === "status"); + expect(statusField!.valid).toBe(false); + }); + + it("allows cloning a Pending invoice", async () => { + const invoice = makeInvoice({ status: "Pending" }); + const validator = new InvoiceCloneabilityValidator({ rpcUrl: "https://rpc.example.com" }); + const report = await validator.validate(invoice); + + const statusField = report.fieldReports.find((f) => f.field === "status"); + expect(statusField).toBeUndefined(); + }); + + it("allows cloning a Released invoice", async () => { + const invoice = makeInvoice({ status: "Released" }); + const validator = new InvoiceCloneabilityValidator({ rpcUrl: "https://rpc.example.com" }); + const report = await validator.validate(invoice); + + const statusField = report.fieldReports.find((f) => f.field === "status"); + expect(statusField).toBeUndefined(); + }); +}); + +describe("InvoiceCloneabilityValidator — deadline check", () => { + it("blocks cloning when deadline is in the past", async () => { + const invoice = makeInvoice({ deadline: PAST_DEADLINE }); + const validator = new InvoiceCloneabilityValidator({ + rpcUrl: "https://rpc.example.com", + minDeadlineBufferMs: 3_600_000, + }); + const report = await validator.validate(invoice); + + const deadlineField = report.fieldReports.find((f) => f.field === "deadline"); + expect(deadlineField).toBeDefined(); + expect(deadlineField!.valid).toBe(false); + expect(deadlineField!.reason).toMatch(/past|buffer/i); + expect(deadlineField!.suggestedFix).toMatch(/newDeadline/); + }); + + it("passes when deadline is sufficiently in the future", async () => { + const invoice = makeInvoice({ deadline: FUTURE_DEADLINE }); + const validator = new InvoiceCloneabilityValidator({ + rpcUrl: "https://rpc.example.com", + minDeadlineBufferMs: 3_600_000, + }); + const report = await validator.validate(invoice); + + const deadlineField = report.fieldReports.find((f) => f.field === "deadline"); + expect(deadlineField).toBeUndefined(); + }); + + it("uses ledger close time (not Date.now) for the deadline check", async () => { + // Set ledger time 2 hours in the future — deadline that appears "past" by wall + // clock should pass because ledger time is what matters + const futureLedgerTime = Math.floor(Date.now() / 1_000) - 7_200; // 2h behind + getLatestLedgerSpy.mockResolvedValue({ + id: "1", + sequence: 1000, + closeTime: futureLedgerTime, + } as any); + + // Deadline is 1h from "now" but 3h from ledger time → passes with 1h buffer + const deadline = Math.floor(Date.now() / 1_000) + 3_600; + const invoice = makeInvoice({ deadline }); + const validator = new InvoiceCloneabilityValidator({ + rpcUrl: "https://rpc.example.com", + minDeadlineBufferMs: 3_600_000, // 1 hour + }); + const report = await validator.validate(invoice); + + // ledger time is 2h behind now, so deadline - ledgerTime = 3h which is > 1h buffer + const deadlineField = report.fieldReports.find((f) => f.field === "deadline"); + expect(deadlineField).toBeUndefined(); + }); +}); + +describe("InvoiceCloneabilityValidator — recipient checks", () => { + it("returns failing field report for a removed recipient", async () => { + loadAccountSpy.mockRejectedValue(new Error("Not Found")); + + const invoice = makeInvoice(); + const validator = new InvoiceCloneabilityValidator({ rpcUrl: "https://rpc.example.com" }); + const report = await validator.validate(invoice); + + const recipientField = report.fieldReports.find((f) => + f.field.startsWith("recipients["), + ); + expect(recipientField).toBeDefined(); + expect(recipientField!.valid).toBe(false); + }); + + it("returns no recipient issues when all accounts are healthy", async () => { + loadAccountSpy.mockResolvedValue(makeAcct() as any); + + const invoice = makeInvoice(); + const validator = new InvoiceCloneabilityValidator({ rpcUrl: "https://rpc.example.com" }); + const report = await validator.validate(invoice); + + const recipientFields = report.fieldReports.filter((f) => + f.field.startsWith("recipients["), + ); + expect(recipientFields).toHaveLength(0); + }); +}); + +describe("InvoiceCloneabilityValidator — combined", () => { + it("returns two failing FieldReport entries for expired deadline + removed recipient", async () => { + loadAccountSpy.mockRejectedValue(new Error("Not Found")); + + const invoice = makeInvoice({ deadline: PAST_DEADLINE }); + const validator = new InvoiceCloneabilityValidator({ + rpcUrl: "https://rpc.example.com", + minDeadlineBufferMs: 3_600_000, + }); + const report = await validator.validate(invoice); + + expect(report.cloneable).toBe(false); + expect(report.fieldReports.length).toBeGreaterThanOrEqual(2); + expect(report.fieldReports.some((f) => f.field === "deadline")).toBe(true); + expect(report.fieldReports.some((f) => f.field.startsWith("recipients["))).toBe(true); + }); + + it("returns cloneable: true with empty fieldReports for a fully valid invoice", async () => { + const invoice = makeInvoice(); + const validator = new InvoiceCloneabilityValidator({ rpcUrl: "https://rpc.example.com" }); + const report = await validator.validate(invoice); + + expect(report.cloneable).toBe(true); + expect(report.fieldReports).toHaveLength(0); + }); +}); + +describe("InvoiceNotCloneableError", () => { + it("embeds the full CloneabilityReport in .details", () => { + const report: CloneabilityReport = { + invoiceId: "99", + cloneable: false, + fieldReports: [ + { + field: "deadline", + valid: false, + reason: "expired", + suggestedFix: "set a new deadline", + }, + ], + }; + + const err = new InvoiceNotCloneableError(report); + expect(err.details).toBe(report); + expect(err.code).toBe("INVOICE_NOT_CLONEABLE"); + expect(err.message).toContain("99"); + expect(err.message).toContain("deadline"); + }); + + it("is instanceof StellarSplitError", async () => { + const { StellarSplitError } = await import("../src/errors.js"); + const err = new InvoiceNotCloneableError({ + invoiceId: "1", + cloneable: false, + fieldReports: [], + }); + expect(err).toBeInstanceOf(StellarSplitError); + }); +}); diff --git a/test/recipientBalancePreCheck.test.ts b/test/recipientBalancePreCheck.test.ts new file mode 100644 index 0000000..7accde0 --- /dev/null +++ b/test/recipientBalancePreCheck.test.ts @@ -0,0 +1,286 @@ +/** + * Tests for RecipientBalancePreCheck (#484) + * + * All Horizon.Server interactions are replaced with vi.spyOn mocks so no + * network calls are made. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { Horizon } from "@stellar/stellar-sdk"; +import { + RecipientBalancePreCheck, +} from "../src/preflight/RecipientBalancePreCheck.js"; +import type { PreCheckResult } from "../src/preflight/RecipientBalancePreCheck.js"; +import { RecipientPreCheckFailedError } from "../src/errors.js"; + +// --------------------------------------------------------------------------- +// Helpers / fixtures +// --------------------------------------------------------------------------- + +const ADDR_VALID = "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN"; +const ADDR_MISSING = "GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPGQS7Z5H4M3I5K6XTLCPUDVKL"; + +const ASSET_CODE = "USDC"; +const ASSET_ISSUER = "GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP"; + +/** Build a minimal AccountResponse-compatible mock object. */ +function makeAccount( + opts: { + sequence?: string; + subentryCount?: number; + xlmBalance?: string; + trustlines?: Array<{ code: string; issuer: string; balance?: string }>; + } = {}, +) { + const { + sequence = "1000", + subentryCount = 2, + xlmBalance = "10.0000000", + trustlines = [], + } = opts; + + const balances: Horizon.HorizonApi.BalanceLine[] = [ + { + asset_type: "native" as const, + balance: xlmBalance, + buying_liabilities: "0.0000000", + selling_liabilities: "0.0000000", + } as Horizon.HorizonApi.BalanceLineNative, + ...trustlines.map( + (t) => + ({ + asset_type: "credit_alphanum4" as const, + asset_code: t.code, + asset_issuer: t.issuer, + balance: t.balance ?? "100.0000000", + limit: "922337203685.4775807", + buying_liabilities: "0.0000000", + selling_liabilities: "0.0000000", + last_modified_ledger: 12345, + is_authorized: true, + is_authorized_to_maintain_liabilities: true, + is_clawback_enabled: false, + }) as Horizon.HorizonApi.BalanceLineAsset, + ), + ]; + + return { + sequenceNumber: () => sequence, + subentry_count: subentryCount, + balances, + }; +} + +// --------------------------------------------------------------------------- +// Setup — spy on Horizon.Server.prototype.loadAccount +// --------------------------------------------------------------------------- + +let loadAccountSpy: ReturnType; + +beforeEach(() => { + loadAccountSpy = vi.spyOn( + Horizon.Server.prototype, + "loadAccount", + ) as ReturnType; +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("RecipientBalancePreCheck — account_exists", () => { + it("returns passed: false when account does not exist", async () => { + loadAccountSpy.mockRejectedValue(new Error("Not Found")); + + const checker = new RecipientBalancePreCheck(); + const [result] = await checker.run([ADDR_MISSING]); + + expect(result!.passed).toBe(false); + const accountCheck = result!.checks.find((c) => c.name === "account_exists"); + expect(accountCheck!.passed).toBe(false); + expect(result!.remediations.length).toBeGreaterThan(0); + expect(result!.remediations[0]).toMatch(/fund account/i); + }); + + it("skips subsequent checks when account does not exist", async () => { + loadAccountSpy.mockRejectedValue(new Error("Not Found")); + + const checker = new RecipientBalancePreCheck({ + assetCode: ASSET_CODE, + assetIssuer: ASSET_ISSUER, + }); + const [result] = await checker.run([ADDR_MISSING]); + + // Only the account_exists check should be present + expect(result!.checks).toHaveLength(1); + expect(result!.checks[0]!.name).toBe("account_exists"); + }); +}); + +describe("RecipientBalancePreCheck — trustline_present", () => { + it("returns trustline_present: false when trustline is missing", async () => { + loadAccountSpy.mockResolvedValue(makeAccount({ subentryCount: 1, xlmBalance: "5.0000000" }) as any); + + const checker = new RecipientBalancePreCheck({ + assetCode: ASSET_CODE, + assetIssuer: ASSET_ISSUER, + }); + const [result] = await checker.run([ADDR_VALID]); + + const tlCheck = result!.checks.find((c) => c.name === "trustline_present"); + expect(tlCheck!.passed).toBe(false); + expect(result!.remediations.some((r) => r.includes("trustline"))).toBe(true); + }); + + it("returns trustline_present: true when correct trustline exists", async () => { + loadAccountSpy.mockResolvedValue( + makeAccount({ + trustlines: [{ code: ASSET_CODE, issuer: ASSET_ISSUER }], + }) as any, + ); + + const checker = new RecipientBalancePreCheck({ + assetCode: ASSET_CODE, + assetIssuer: ASSET_ISSUER, + }); + const [result] = await checker.run([ADDR_VALID]); + + const tlCheck = result!.checks.find((c) => c.name === "trustline_present"); + expect(tlCheck!.passed).toBe(true); + }); + + it("skips trustline check when no assetCode/assetIssuer given", async () => { + loadAccountSpy.mockResolvedValue(makeAccount() as any); + + const checker = new RecipientBalancePreCheck(); + const [result] = await checker.run([ADDR_VALID]); + + const tlCheck = result!.checks.find((c) => c.name === "trustline_present"); + expect(tlCheck).toBeUndefined(); + }); +}); + +describe("RecipientBalancePreCheck — minimum_reserve", () => { + it("fails when native balance is below minimum reserve", async () => { + // subentries=2 → reserve = (2+2)*0.5 = 2 XLM; balance = 1 XLM → shortfall 1 XLM + loadAccountSpy.mockResolvedValue( + makeAccount({ subentryCount: 2, xlmBalance: "1.0000000" }) as any, + ); + + const checker = new RecipientBalancePreCheck(); + const [result] = await checker.run([ADDR_VALID]); + + const reserveCheck = result!.checks.find((c) => c.name === "minimum_reserve"); + expect(reserveCheck!.passed).toBe(false); + expect(result!.remediations.some((r) => r.includes("reserve"))).toBe(true); + // Shortfall detail should mention XLM + expect(reserveCheck!.detail).toMatch(/shortfall/i); + }); + + it("passes when native balance satisfies reserve", async () => { + // subentries=2 → reserve = 2 XLM; balance = 10 XLM → ok + loadAccountSpy.mockResolvedValue( + makeAccount({ subentryCount: 2, xlmBalance: "10.0000000" }) as any, + ); + + const checker = new RecipientBalancePreCheck(); + const [result] = await checker.run([ADDR_VALID]); + + const reserveCheck = result!.checks.find((c) => c.name === "minimum_reserve"); + expect(reserveCheck!.passed).toBe(true); + }); + + it("includes exact XLM shortfall in the remediation hint", async () => { + // reserve = (2+4)*0.5 = 3 XLM; balance = 1.5 → shortfall = 1.5 XLM + loadAccountSpy.mockResolvedValue( + makeAccount({ subentryCount: 4, xlmBalance: "1.5000000" }) as any, + ); + + const checker = new RecipientBalancePreCheck(); + const [result] = await checker.run([ADDR_VALID]); + + const hint = result!.remediations.find((r) => r.includes("reserve")); + expect(hint).toBeDefined(); + expect(hint).toMatch(/1\.5/); + }); +}); + +describe("RecipientBalancePreCheck — fully valid recipient", () => { + it("passes all four checks and returns remediations: []", async () => { + loadAccountSpy.mockResolvedValue( + makeAccount({ + subentryCount: 2, + xlmBalance: "10.0000000", + trustlines: [{ code: ASSET_CODE, issuer: ASSET_ISSUER }], + }) as any, + ); + + const checker = new RecipientBalancePreCheck({ + assetCode: ASSET_CODE, + assetIssuer: ASSET_ISSUER, + }); + const [result] = await checker.run([ADDR_VALID]); + + expect(result!.passed).toBe(true); + expect(result!.remediations).toHaveLength(0); + expect(result!.checks.every((c) => c.passed)).toBe(true); + }); +}); + +describe("RecipientBalancePreCheck — multiple recipients", () => { + it("returns one result per recipient", async () => { + loadAccountSpy + .mockResolvedValueOnce(makeAccount() as any) + .mockRejectedValueOnce(new Error("Not Found")); + + const checker = new RecipientBalancePreCheck(); + const results = await checker.run([ADDR_VALID, ADDR_MISSING]); + + expect(results).toHaveLength(2); + expect(results[0]!.recipient).toBe(ADDR_VALID); + expect(results[1]!.recipient).toBe(ADDR_MISSING); + expect(results[0]!.passed).toBe(true); + expect(results[1]!.passed).toBe(false); + }); + + it("runAndGetFailing returns only failing recipients", async () => { + loadAccountSpy + .mockResolvedValueOnce(makeAccount() as any) + .mockRejectedValueOnce(new Error("Not Found")); + + const checker = new RecipientBalancePreCheck(); + const failing = await checker.runAndGetFailing([ADDR_VALID, ADDR_MISSING]); + + expect(failing).toHaveLength(1); + expect(failing[0]!.recipient).toBe(ADDR_MISSING); + }); +}); + +describe("RecipientPreCheckFailedError", () => { + it("lists all failing recipients in the error message", () => { + const fakeResult: PreCheckResult = { + recipient: ADDR_MISSING, + passed: false, + checks: [ + { name: "account_exists", passed: false, detail: "not found" }, + ], + remediations: ["Fund the account"], + }; + + const err = new RecipientPreCheckFailedError([fakeResult]); + expect(err.code).toBe("RECIPIENT_PRE_CHECK_FAILED"); + expect(err.failingResults).toHaveLength(1); + expect(err.message).toContain(ADDR_MISSING); + expect(err.message).toContain("account_exists"); + }); + + it("is instanceof StellarSplitError", async () => { + const { StellarSplitError } = await import("../src/errors.js"); + const err = new RecipientPreCheckFailedError([]); + expect(err).toBeInstanceOf(StellarSplitError); + }); +});