diff --git a/src/assetIssuerVerifier.ts b/src/assetIssuerVerifier.ts new file mode 100644 index 0000000..2d02984 --- /dev/null +++ b/src/assetIssuerVerifier.ts @@ -0,0 +1,180 @@ +/** + * Asset issuer verification pipeline for StellarSplit. + * + * Verifies that an asset issuer account exists on the network, has a valid + * home domain, publishes a proper stellar.toml, lists the asset code + * in its CURRENCIES section, and is not frozen or deauthorised. + * This prevents fraudulent lookalike assets from being used in invoice payments. + */ + +import { Server, StellarTomlResolver } from "@stellar/stellar-sdk"; +import type { IssuerVerificationResult } from "./types.js"; + +/** + * Verify that an asset issuer account is legitimate. + * + * Performs a multi-step verification: + * 1. Loads the issuer account to confirm existence + * 2. Checks the account's home_domain flag + * 3. Checks the account is not frozen or deauthorised (auth_required/revocable check) + * 4. Fetches stellar.toml from the home domain + * 5. Validates the asset code appears in the CURRENCIES section + * + * @param horizonUrl - Full URL of the Horizon API (e.g. "https://horizon.stellar.org"). + * @param issuerId - Stellar G-address of the asset issuer. + * @param assetCode - The asset code to verify (e.g. "USDC"). + * @param opts - Optional timeout in milliseconds (default: 15_000). + * @returns A detailed {@link IssuerVerificationResult}. + */ +export async function verifyAssetIssuer( + horizonUrl: string, + issuerId: string, + assetCode: string, + opts?: { timeoutMs?: number }, +): Promise { + const timeoutMs = opts?.timeoutMs ?? 15_000; + const errors: string[] = []; + let accountExists = false; + let homeDomain: string | null = null; + let tomlFound = false; + let assetInToml = false; + let frozen = false; + let deauthorised = false; + + const server = new Server(horizonUrl); + + // Step 1: Load the issuer account + let accountRaw: Record | null = null; + try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + try { + const account = await server.loadAccount(issuerId); + accountExists = true; + + // Extract account data as raw object + accountRaw = account as unknown as Record; + homeDomain = (accountRaw.home_domain as string) ?? null; + + if (!homeDomain) { + errors.push("Issuer account has no home_domain set"); + } + + // Step 2: Check for frozen/deauthorised flags + const flags = (accountRaw.flags as Record) ?? {}; + const authRequired = flags.auth_required === true || flags.auth_required === 1; + const authRevocable = flags.auth_revocable === true || flags.auth_revocable === 1; + const authImmutable = flags.auth_immutable === true || flags.auth_immutable === 1; + + if (authImmutable) { + errors.push("Issuer account has auth_immutable flag set — issuer cannot update authorisation"); + deauthorised = true; + } + if (authRequired && authRevocable) { + // Both set: issuer can freeze — but this is expected for regulated assets + // Not inherently an error, but worth flagging if no toml validation + } + if (authRequired) { + errors.push("Issuer requires authorisation (auth_required) — may deauthorise trustlines"); + deauthorised = true; + } + + // Check if account has any frozen trustlines (relevant flag on the issuer) + const authClawbackEnabled = flags.auth_clawback_enabled === true || flags.auth_clawback_enabled === 1; + if (authClawbackEnabled) { + errors.push("Issuer has clawback enabled — may freeze and claw back assets"); + frozen = true; + } + } finally { + clearTimeout(timer); + } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + if ((err as { name?: string }).name === "AbortError" || message.includes("abort")) { + errors.push(`Timeout: could not load issuer account within ${timeoutMs}ms`); + } else if (message.includes("Not Found") || message.includes("404")) { + errors.push("Issuer account not found on the network"); + } else { + errors.push(`Failed to load issuer account: ${message}`); + } + + return { + verified: false, + issuerId, + accountExists: false, + homeDomain: null, + tomlFound: false, + assetInToml: false, + assetCode, + errors, + }; + } + + // If no home domain, we can't proceed with toml verification + if (!homeDomain) { + return { + verified: false, + issuerId, + accountExists: true, + homeDomain: null, + tomlFound: false, + assetInToml: false, + assetCode, + errors, + }; + } + + // Step 3: Fetch stellar.toml + try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + try { + const toml = await StellarTomlResolver.resolve(homeDomain, { + signal: controller.signal, + } as unknown as { signal: AbortSignal }); + + tomlFound = true; + + // Step 4: Check if the asset code appears in CURRENCIES + const currencies = toml.CURRENCIES ?? []; + if (Array.isArray(currencies)) { + assetInToml = currencies.some( + (c: { code?: string; issuer?: string }) => + c.code === assetCode && c.issuer === issuerId, + ); + + if (!assetInToml) { + errors.push( + `Asset code "${assetCode}" with issuer "${issuerId}" not found in stellar.toml CURRENCIES`, + ); + } + } else { + errors.push("stellar.toml has no CURRENCIES section"); + } + } finally { + clearTimeout(timer); + } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + if ((err as { name?: string }).name === "AbortError" || message.includes("abort")) { + errors.push(`Timeout: could not fetch stellar.toml within ${timeoutMs}ms`); + } else { + errors.push(`Failed to fetch stellar.toml from ${homeDomain}: ${message}`); + } + } + + const verified = accountExists && tomlFound && assetInToml && errors.length === 0; + + return { + verified, + issuerId, + accountExists, + homeDomain, + tomlFound, + assetInToml, + assetCode, + errors, + }; +} diff --git a/src/cursorTracker.ts b/src/cursorTracker.ts new file mode 100644 index 0000000..5ffc043 --- /dev/null +++ b/src/cursorTracker.ts @@ -0,0 +1,59 @@ +/** + * Cursor tracker for persisting Horizon paging tokens. + * + * Provides an in-memory and pluggable storage-backed cursor store so that + * the horizon paginator can resume from the last-seen position across + * restarts or page walks. + */ + +import type { CursorStore } from "./types.js"; + +/** + * In-memory cursor store suitable for session-scoped pagination. + * Cursors are lost when the process exits. + */ +export class InMemoryCursorStore implements CursorStore { + private store = new Map(); + + async save(key: string, cursor: string): Promise { + this.store.set(key, cursor); + } + + async load(key: string): Promise { + return this.store.get(key) ?? null; + } + + async delete(key: string): Promise { + this.store.delete(key); + } + + /** Remove all saved cursors. */ + clear(): void { + this.store.clear(); + } +} + +/** Singleton in-memory store shared across the module. */ +let defaultStore: CursorStore = new InMemoryCursorStore(); + +/** + * Override the default cursor store. + * Useful for plugging in localStorage, IndexedDB, or a remote store. + */ +export function setDefaultCursorStore(store: CursorStore): void { + defaultStore = store; +} + +/** + * Get the current default cursor store. + */ +export function getDefaultCursorStore(): CursorStore { + return defaultStore; +} + +/** + * Build a namespaced cursor key from a base name and namespace. + */ +export function buildCursorKey(namespace: string, name: string): string { + return `${namespace}:${name}`; +} diff --git a/src/horizonPaginator.ts b/src/horizonPaginator.ts new file mode 100644 index 0000000..1e00976 --- /dev/null +++ b/src/horizonPaginator.ts @@ -0,0 +1,97 @@ +/** + * Horizon collection paginator. + * + * Wraps cursor-based Horizon collection endpoints and exposes an async + * iterator that transparently walks all pages, yielding records until + * the collection is exhausted or a configurable max-records limit is hit. + * + * Integrates with {@link cursorTracker} to persist the last-seen paging + * token for resumable pagination. + */ + +import type { CollectionPage, HorizonPaginatorOptions } from "./types.js"; +import { buildCursorKey, getDefaultCursorStore } from "./cursorTracker.js"; + +/** Default namespace for cursor store keys. */ +const DEFAULT_NAMESPACE = "horizon"; + +/** + * Create an async iterable iterator that walks all pages of a Horizon + * collection endpoint. + * + * @param initialPage - The first page of results (obtained from a Horizon call builder). + * @param opts - Optional configuration for record limits and cursor persistence. + * + * @example + * ```typescript + * const server = new Server("https://horizon.stellar.org"); + * const page = await server.payments().forAccount(addr).limit(200).call(); + * + * for await (const payment of paginate(page, { maxRecords: 500 })) { + * console.log(payment); + * } + * ``` + */ +export async function* paginate( + initialPage: CollectionPage, + opts?: HorizonPaginatorOptions, +): AsyncIterableIterator { + const maxRecords = opts?.maxRecords; + const cursorStore = opts?.cursorStore ?? getDefaultCursorStore(); + const namespace = opts?.cursorNamespace ?? DEFAULT_NAMESPACE; + + let yielded = 0; + let currentPage: CollectionPage | null = initialPage; + + while (currentPage) { + const records = currentPage.records ?? []; + const batch = maxRecords !== undefined + ? records.slice(0, maxRecords - yielded) + : records; + + for (const record of batch) { + if (maxRecords !== undefined && yielded >= maxRecords) return; + yielded++; + yield record; + } + + // Persist the cursor of the last record we just yielded + if (batch.length > 0) { + const lastRecord = batch[batch.length - 1] as unknown; + if (lastRecord && typeof (lastRecord as Record).paging_token === "string") { + const token = (lastRecord as Record).paging_token as string; + if (cursorStore) { + const cursorKey = buildCursorKey(namespace, "last"); + await cursorStore.save(cursorKey, token).catch(() => { + // Cursor save failures are non-fatal + }); + } + } + } + + if (maxRecords !== undefined && yielded >= maxRecords) return; + + // Fetch next page + currentPage = await currentPage.next(); + } +} + +/** + * Collect all records from a paginated collection into a single array. + * + * Convenience wrapper around {@link paginate}. + * + * @param initialPage - The first page of results. + * @param opts - Optional configuration. + * @returns All records across all pages (up to maxRecords). + */ +export async function collectAll( + initialPage: CollectionPage, + opts?: HorizonPaginatorOptions, +): Promise { + const results: T[] = []; + for await (const record of paginate(initialPage, opts)) { + results.push(record); + } + return results; +} diff --git a/src/memoBuilder.ts b/src/memoBuilder.ts new file mode 100644 index 0000000..ed7b1c3 --- /dev/null +++ b/src/memoBuilder.ts @@ -0,0 +1,167 @@ +/** + * Structured memo builder for StellarSplit invoice payments. + * + * Encodes invoice ID, split protocol version, and payer identity into a + * canonical memo format that fits within Stellar's 28-byte text memo limit. + * + * Format: `SS:v{version}:{invoiceId}:{payerSuffix}` + * - "SS:" prefix (3 bytes) identifies StellarSplit memos + * - `v{version}` – split protocol version + * - `{invoiceId}` – the full invoice ID + * - `{payerSuffix}` – last 8 characters of the payer G-address + * - Total: 3 + len(version) + 1 + len(invoiceId) + 1 + 8 ≤ 28 bytes + */ + +import { Memo } from "@stellar/stellar-sdk"; +import type { ParsedMemo, SplitConfig } from "./types.js"; + +/** Magic prefix identifying StellarSplit-encoded memos. */ +export const MEMO_PREFIX = "SS:"; + +/** Maximum length of a Stellar text memo in bytes. */ +const MAX_MEMO_BYTES = 28; + +/** Number of trailing payer-address characters stored in the memo. */ +const PAYER_SUFFIX_LENGTH = 8; + +/** + * Build a canonical text memo encoding invoice ID, split version, and payer + * identity for use with {@link TransactionBuilder.addMemo}. + * + * @param invoiceId - The invoice ID to encode. + * @param config - Split configuration containing the protocol version. + * @param payerAddress - Stellar G-address of the payer. + * @returns A {@link Memo} instance suitable for transaction attachment. + * @throws If the encoded memo exceeds Stellar's 28-byte text memo limit. + */ +export function buildMemo( + invoiceId: string, + config: SplitConfig, + payerAddress: string, +): Memo { + if (!payerAddress || payerAddress.length < PAYER_SUFFIX_LENGTH) { + throw new Error( + `Payer address must be at least ${PAYER_SUFFIX_LENGTH} characters`, + ); + } + + const payerSuffix = payerAddress.slice(-PAYER_SUFFIX_LENGTH); + const memo = `${MEMO_PREFIX}v${config.version}:${invoiceId}:${payerSuffix}`; + + const encoder = new TextEncoder(); + const bytes = encoder.encode(memo); + if (bytes.length > MAX_MEMO_BYTES) { + throw new Error( + `Memo exceeds ${MAX_MEMO_BYTES} bytes (${bytes.length} bytes): "${memo}"`, + ); + } + + return Memo.text(memo); +} + +/** + * Build a {@link Memo.hash} from the structured invoice payment data. + * + * Uses the first 32 bytes of the canonical UTF-8 encoding as the hash + * memo value. Memo.hash allows up to 32 bytes and is useful when the + * text representation would exceed the 28-byte limit. + * + * @param invoiceId - The invoice ID to encode. + * @param config - Split configuration containing the protocol version. + * @param payerAddress - Stellar G-address of the payer. + * @returns A Memo.hash instance. + */ +export function buildHashMemo( + invoiceId: string, + config: SplitConfig, + payerAddress: string, +): Memo { + const encoder = new TextEncoder(); + const data = `${MEMO_PREFIX}v${config.version}:${invoiceId}:${payerAddress}`; + const encoded = encoder.encode(data); + // Take up to 32 bytes for the hash memo + const hashBuffer = new Uint8Array(32); + hashBuffer.set(encoded.slice(0, Math.min(encoded.length, 32))); + return Memo.hash(Buffer.from(hashBuffer)); +} + +/** + * Build a {@link Memo.id} from a numeric invoice ID. + * + * Memo.id stores a uint64 identifier directly on the ledger. This is + * the most space-efficient option when only the invoice ID is needed. + * + * @param invoiceId - Numeric invoice ID (must fit in uint64). + * @returns A Memo.id instance. + */ +export function buildIdMemo(invoiceId: string | number): Memo { + const id = BigInt(invoiceId); + return Memo.id(id.toString()); +} + +/** + * Parse a Stellar memo back into its structured components. + * + * Attempts to extract invoice ID, version, and payer suffix from the + * canonical `SS:v{version}:{invoiceId}:{payerSuffix}` format. + * + * @param memo - The Stellar memo to parse. + * @returns A {@link ParsedMemo} with extracted fields. + * @throws If the memo is not a text memo or does not match the expected format. + */ +export function parseMemo(memo: Memo): ParsedMemo { + if (memo.type !== "text") { + throw new Error( + `Cannot parse memo of type "${memo.type}": only text memos are supported`, + ); + } + + const value = memo.value as string; + if (!value || !value.startsWith(MEMO_PREFIX)) { + throw new Error( + `Memo does not start with expected prefix "${MEMO_PREFIX}": "${value}"`, + ); + } + + const payload = value.slice(MEMO_PREFIX.length); // e.g. "v1:42:ABCDEFGH" + const versionMatch = payload.match(/^v(\d+):/); + if (!versionMatch) { + throw new Error(`Invalid memo format: missing version in "${value}"`); + } + + const version = parseInt(versionMatch[1]!, 10); + const afterVersion = payload.slice(versionMatch[0].length); // e.g. "42:ABCDEFGH" + + const lastColon = afterVersion.lastIndexOf(":"); + if (lastColon < 0) { + throw new Error(`Invalid memo format: missing payer suffix in "${value}"`); + } + + const invoiceId = afterVersion.slice(0, lastColon); + const payerId = afterVersion.slice(lastColon + 1); + + if (!invoiceId) { + throw new Error(`Invalid memo format: empty invoice ID in "${value}"`); + } + + if (payerId.length !== PAYER_SUFFIX_LENGTH) { + throw new Error( + `Invalid memo format: payer suffix must be ${PAYER_SUFFIX_LENGTH} characters in "${value}"`, + ); + } + + return { invoiceId, version, payerId }; +} + +/** + * Check whether a Memo matches the StellarSplit canonical format + * without throwing. + * + * @param memo - The memo to check. + * @returns True if the memo is a text memo starting with the "SS:" prefix. + */ +export function isStellarSplitMemo(memo: Memo): boolean { + if (memo.type !== "text") return false; + const value = memo.value as string; + return typeof value === "string" && value.startsWith(MEMO_PREFIX); +} diff --git a/src/sep/sep24Handler.ts b/src/sep/sep24Handler.ts new file mode 100644 index 0000000..74215ea --- /dev/null +++ b/src/sep/sep24Handler.ts @@ -0,0 +1,320 @@ +/** + * SEP-24 interactive transfer handler for StellarSplit. + * + * Manages the full interactive deposit and withdrawal lifecycle: + * initiation, IFRAME URL generation, status polling, and completion + * detection. Integrates with SEP-10 JWT authentication via + * {@link resolveToken} and emits status changes via a typed event + * emitter. + * + * @see https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0024.md + */ + +import { StellarTomlResolver } from "@stellar/stellar-sdk"; +import { TypedEventEmitter } from "../events/TypedEventEmitter.js"; +import type { + Sep24TransactionRecord, + Sep24Status, + Sep24StatusChangedEvent, +} from "../types.js"; + +// --------------------------------------------------------------------------- +// Event map +// --------------------------------------------------------------------------- + +/** Events emitted by {@link Sep24Handler}. */ +export interface Sep24HandlerEventMap { + sep24StatusChanged: Sep24StatusChangedEvent; +} + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Parameters for initiating a SEP-24 interactive transfer. */ +export interface Sep24InitParams { + /** Anchor service base URL (from stellar.toml TRANSFER_SERVER). */ + anchorUrl: string; + /** SEP-10 JWT token for authentication. */ + jwt: string; + /** Type of transfer. */ + kind: "deposit" | "withdrawal"; + /** Asset code (e.g., "USDC"). */ + assetCode: string; + /** Asset issuer's Stellar address. */ + assetIssuer: string; + /** Amount requested in stroops. */ + amount: bigint; + /** Stellar account receiving the deposit or sending the withdrawal. */ + account: string; + /** Optional callback URL for anchor to POST status updates. */ + callbackUrl?: string; + /** Optional KYC fields to pre-fill. */ + kycFields?: Record; + /** Polling interval in milliseconds (default: 3000). */ + pollIntervalMs?: number; +} + +/** Success response from a SEP-24 initiation request. */ +interface Sep24InitResponse { + id: string; + url: string; + interactive_url?: string; +} + +// --------------------------------------------------------------------------- +// Handler +// --------------------------------------------------------------------------- + +/** + * Manages a single SEP-24 interactive transfer lifecycle. + * + * Call {@link init} to start the transfer, then monitor the + * `sep24StatusChanged` event or call {@link getTransaction} to + * retrieve the current state. + * + * @example + * ```typescript + * const handler = new Sep24Handler(); + * handler.on("sep24StatusChanged", (event) => { + * if (event.transaction.status === "completed") { + * console.log("Transfer complete!"); + * } + * }); + * + * const txn = await handler.init({ + * anchorUrl: "https://anchor.example.com", + * jwt: sep10Token, + * kind: "deposit", + * assetCode: "USDC", + * assetIssuer: "G...ISSUER", + * amount: 100_0000000n, + * account: "G...USER", + * }); + * ``` + */ +export class Sep24Handler extends TypedEventEmitter { + private transaction: Sep24TransactionRecord | null = null; + private polling = false; + private pollTimer: ReturnType | null = null; + private jwt = ""; + + /** + * Initiate a SEP-24 interactive deposit or withdrawal. + * + * Fetches the anchor's interactive URL, stores the transaction record, + * and begins polling for status updates. + * + * @param params - Transfer initiation parameters. + * @returns The initialized transaction record. + */ + async init(params: Sep24InitParams): Promise { + const pollIntervalMs = params.pollIntervalMs ?? 3000; + this.jwt = params.jwt; + + const endpoint = + params.kind === "deposit" + ? "/transactions/deposit/interactive" + : "/transactions/withdraw/interactive"; + + const url = `${params.anchorUrl.replace(/\/$/, "")}${endpoint}`; + + const body = new URLSearchParams({ + asset_code: params.assetCode, + asset_issuer: params.assetIssuer, + account: params.account, + amount: params.amount.toString(), + }); + + if (params.callbackUrl) { + body.append("callback_url", params.callbackUrl); + } + + if (params.kycFields) { + for (const [key, value] of Object.entries(params.kycFields)) { + body.append(key, value); + } + } + + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Authorization: `Bearer ${params.jwt}`, + }, + body: body.toString(), + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => "Unknown error"); + throw new Error( + `SEP-24 initiation failed (${response.status}): ${errorText}`, + ); + } + + const data = (await response.json()) as Sep24InitResponse; + + const now = Math.floor(Date.now() / 1000); + const record: Sep24TransactionRecord = { + id: data.id, + kind: params.kind, + status: "incomplete", + amount: params.amount, + assetCode: params.assetCode, + assetIssuer: params.assetIssuer, + interactiveUrl: data.url ?? data.interactive_url ?? null, + stellarTxId: null, + startedAt: now, + updatedAt: now, + anchorUrl: params.anchorUrl, + kycUrl: null, + errorMessage: null, + }; + + this.transaction = record; + this.startPolling(pollIntervalMs); + + return record; + } + + /** + * Get the current transaction record, or null if not yet initialized. + */ + getTransaction(): Sep24TransactionRecord | null { + return this.transaction; + } + + /** + * Stop polling and clean up resources. + */ + destroy(): void { + this.stopPolling(); + this.removeAllListeners(); + this.transaction = null; + } + + // ----------------------------------------------------------------------- + // Internal polling + // ----------------------------------------------------------------------- + + private startPolling(intervalMs: number): void { + if (this.polling) return; + this.polling = true; + + this.pollTimer = setInterval(() => { + void this.pollStatus(); + }, intervalMs); + } + + private stopPolling(): void { + this.polling = false; + if (this.pollTimer !== null) { + clearInterval(this.pollTimer); + this.pollTimer = null; + } + } + + private async pollStatus(): Promise { + if (!this.transaction) return; + + const { anchorUrl, id } = this.transaction; + const url = `${anchorUrl.replace(/\/$/, "")}/transaction?id=${encodeURIComponent(id)}`; + + try { + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${this.jwt}`, + }, + }); + + if (!response.ok) return; + + const data = (await response.json()) as { + transaction: { + id: string; + status: string; + stellar_transaction_id?: string; + completed_at?: string; + message?: string; + }; + }; + + const txData = data.transaction; + if (!txData) return; + + const newStatus = normalizeStatus(txData.status); + const previousStatus = this.transaction.status; + + if (newStatus !== previousStatus) { + this.transaction = { + ...this.transaction, + status: newStatus, + stellarTxId: txData.stellar_transaction_id ?? this.transaction.stellarTxId, + updatedAt: Math.floor(Date.now() / 1000), + errorMessage: newStatus === "error" ? (txData.message ?? null) : this.transaction.errorMessage, + }; + + this.emit("sep24StatusChanged", { + transaction: this.transaction, + previousStatus, + }); + + // Stop polling on terminal states + if (isTerminalStatus(newStatus)) { + this.stopPolling(); + } + } + } catch { + // Swallow polling errors — will retry on next interval + } + } +} + +// --------------------------------------------------------------------------- +// Helper: Resolve anchor info from stellar.toml +// --------------------------------------------------------------------------- + +/** + * Fetches the transfer server URL from an anchor's stellar.toml. + * + * @param homeDomain - The anchor's home domain (e.g. "anchorusd.com"). + * @returns The TRANSFER_SERVER URL, or null if not found. + */ +export async function resolveAnchorTransferServer( + homeDomain: string, +): Promise { + try { + const toml = await StellarTomlResolver.resolve(homeDomain); + return (toml.TRANSFER_SERVER as string) ?? null; + } catch { + return null; + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Map raw anchor status strings to canonical {@link Sep24Status}. */ +function normalizeStatus(raw: string): Sep24Status { + const status = raw.toLowerCase().trim(); + const valid: Sep24Status[] = [ + "incomplete", + "pending_user_transfer_start", + "pending_anchor", + "pending_stellar", + "pending_external", + "completed", + "error", + "refunded", + ]; + if ((valid as string[]).includes(status)) { + return status as Sep24Status; + } + return "incomplete"; +} + +/** Statuses that indicate the transfer is final. */ +function isTerminalStatus(status: Sep24Status): boolean { + return status === "completed" || status === "error" || status === "refunded"; +} diff --git a/src/types.ts b/src/types.ts index 4bc2975..a2f3713 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1383,3 +1383,140 @@ export interface SignedBridgeProof { /** Source-chain address of the signer. */ signerAddress: string; } + +// --------------------------------------------------------------------------- +// Memo Builder Types +// --------------------------------------------------------------------------- + +/** + * Configuration for the split memo builder, defining the version of the split + * protocol used when encoding memo data. + */ +export interface SplitConfig { + /** Protocol version number for the split memo format. */ + version: number; +} + +/** + * Parsed representation of a canonical StellarSplit memo. + */ +export interface ParsedMemo { + /** The invoice ID extracted from the memo. */ + invoiceId: string; + /** The split protocol version encoded in the memo. */ + version: number; + /** The payer's Stellar address suffix (last 8 chars) used for identification. */ + payerId: string; +} + +// --------------------------------------------------------------------------- +// Asset Issuer Verification Types +// --------------------------------------------------------------------------- + +/** Result of verifying an asset issuer's on-chain identity and metadata. */ +export interface IssuerVerificationResult { + /** Whether the issuer passed all verification checks. */ + verified: boolean; + /** The issuer account ID that was checked. */ + issuerId: string; + /** Whether the issuer account exists on the network. */ + accountExists: boolean; + /** The home domain claimed by the issuer account, if any. */ + homeDomain: string | null; + /** Whether a valid stellar.toml was found at the home domain. */ + tomlFound: boolean; + /** Whether the asset code was listed in the CURRENCIES section of the toml. */ + assetInToml: boolean; + /** The asset code that was verified against the toml. */ + assetCode: string | null; + /** List of human-readable failure reasons when verified is false. */ + errors: string[]; +} + +// --------------------------------------------------------------------------- +// SEP-24 Interactive Transfer Types +// --------------------------------------------------------------------------- + +/** Lifecycle status of a SEP-24 interactive transfer. */ +export type Sep24Status = + | "incomplete" + | "pending_user_transfer_start" + | "pending_anchor" + | "pending_stellar" + | "pending_external" + | "completed" + | "error" + | "refunded"; + +/** A record tracking a single SEP-24 interactive deposit or withdrawal. */ +export interface Sep24TransactionRecord { + /** SEP-24 transaction ID returned by the anchor. */ + id: string; + /** Type of transfer: deposit or withdrawal. */ + kind: "deposit" | "withdrawal"; + /** Current lifecycle status. */ + status: Sep24Status; + /** Amount requested in the transfer (in stroops). */ + amount: bigint; + /** Asset code (e.g., "USDC"). */ + assetCode: string; + /** The anchor's Stellar address for the asset issuer. */ + assetIssuer: string; + /** The interactive IFRAME URL the user should visit. */ + interactiveUrl: string | null; + /** Stellar transaction ID once the transfer completes on-chain. */ + stellarTxId: string | null; + /** Unix timestamp when the transaction was initiated. */ + startedAt: number; + /** Unix timestamp of the last status update. */ + updatedAt: number; + /** Anchor service endpoint used for this transfer. */ + anchorUrl: string; + /** Optional KYC/verification URL if required by the anchor. */ + kycUrl: string | null; + /** Optional human-readable error message when status is "error". */ + errorMessage: string | null; +} + +/** Event emitted when a SEP-24 transaction status changes. */ +export interface Sep24StatusChangedEvent { + /** The transaction record with updated status. */ + transaction: Sep24TransactionRecord; + /** The previous status before this change. */ + previousStatus: Sep24Status; +} + +// --------------------------------------------------------------------------- +// Horizon Paginator Types +// --------------------------------------------------------------------------- + +/** + * Minimal interface for a Horizon collection page that supports + * cursor-based pagination via a .next() method. + */ +export interface CollectionPage { + /** Records in the current page. */ + records: T[]; + /** Fetch the next page, or return null when exhausted. */ + next(): Promise | null>; +} + +/** Configuration options for the horizon paginator. */ +export interface HorizonPaginatorOptions { + /** Maximum number of records to yield across all pages. Default: unlimited. */ + maxRecords?: number; + /** Optional cursor store for persisting the last-seen paging token. */ + cursorStore?: CursorStore; + /** Optional namespace for cursor storage keys (default: "horizon"). */ + cursorNamespace?: string; +} + +/** Persistence interface for cursor tracking. */ +export interface CursorStore { + /** Save a cursor value under a named key. */ + save(key: string, cursor: string): Promise; + /** Load a previously saved cursor value, or null if not found. */ + load(key: string): Promise; + /** Delete a saved cursor. */ + delete(key: string): Promise; +} diff --git a/test/assetIssuerVerifier.test.ts b/test/assetIssuerVerifier.test.ts new file mode 100644 index 0000000..dda9c98 --- /dev/null +++ b/test/assetIssuerVerifier.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Mock @stellar/stellar-sdk at module level +vi.mock("@stellar/stellar-sdk", () => { + return { + Server: vi.fn(), + StellarTomlResolver: { + resolve: vi.fn(), + }, + }; +}); + +import { Server, StellarTomlResolver } from "@stellar/stellar-sdk"; +import { verifyAssetIssuer } from "../src/assetIssuerVerifier.js"; + +const ISS = "GISSUER1234567890123456789012345678901234567"; +const HOR = "https://horizon-testnet.stellar.org"; + +describe("verifyAssetIssuer", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns verified: true when all checks pass", async () => { + const mockServer = { + loadAccount: vi.fn().mockResolvedValue({ + home_domain: "anchor.example.com", + flags: {}, + }), + }; + (Server as unknown as ReturnType).mockReturnValue(mockServer); + (StellarTomlResolver.resolve as ReturnType).mockResolvedValue({ + CURRENCIES: [ + { code: "USDC", issuer: ISS }, + ], + }); + + const result = await verifyAssetIssuer(HOR, ISS, "USDC"); + expect(result.verified).toBe(true); + expect(result.accountExists).toBe(true); + expect(result.homeDomain).toBe("anchor.example.com"); + expect(result.tomlFound).toBe(true); + expect(result.assetInToml).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it("returns verified: false when account not found", async () => { + const mockServer = { + loadAccount: vi.fn().mockRejectedValue(new Error("Not Found")), + }; + (Server as unknown as ReturnType).mockReturnValue(mockServer); + + const result = await verifyAssetIssuer(HOR, "GNONEXISTENT123456789012345678", "USDC"); + expect(result.verified).toBe(false); + expect(result.accountExists).toBe(false); + expect(result.errors.some((e) => e.toLowerCase().includes("not found"))).toBe(true); + }); + + it("returns verified: false when no home domain", async () => { + const mockServer = { + loadAccount: vi.fn().mockResolvedValue({ + flags: {}, + }), + }; + (Server as unknown as ReturnType).mockReturnValue(mockServer); + + const result = await verifyAssetIssuer(HOR, ISS, "USDC"); + expect(result.verified).toBe(false); + expect(result.accountExists).toBe(true); + expect(result.homeDomain).toBeNull(); + expect(result.errors.some((e) => e.toLowerCase().includes("home_domain"))).toBe(true); + }); + + it("returns verified: false when asset not in toml CURRENCIES", async () => { + const mockServer = { + loadAccount: vi.fn().mockResolvedValue({ + home_domain: "anchor.example.com", + flags: {}, + }), + }; + (Server as unknown as ReturnType).mockReturnValue(mockServer); + (StellarTomlResolver.resolve as ReturnType).mockResolvedValue({ + CURRENCIES: [{ code: "EURT", issuer: "GOTHERISSUER" }], + }); + + const result = await verifyAssetIssuer(HOR, ISS, "USDC"); + expect(result.verified).toBe(false); + expect(result.tomlFound).toBe(true); + expect(result.assetInToml).toBe(false); + expect(result.errors.some((e) => e.toLowerCase().includes("currencies"))).toBe(true); + }); + + it("detects frozen/deauthorised issuer flags", async () => { + const mockServer = { + loadAccount: vi.fn().mockResolvedValue({ + home_domain: "anchor.example.com", + flags: { + auth_required: true, + auth_immutable: true, + auth_clawback_enabled: true, + }, + }), + }; + (Server as unknown as ReturnType).mockReturnValue(mockServer); + (StellarTomlResolver.resolve as ReturnType).mockResolvedValue({ + CURRENCIES: [{ code: "USDC", issuer: ISS }], + }); + + const result = await verifyAssetIssuer(HOR, ISS, "USDC"); + expect(result.verified).toBe(false); + expect(result.errors.some((e) => e.toLowerCase().includes("auth_required"))).toBe(true); + expect(result.errors.some((e) => e.toLowerCase().includes("auth_immutable"))).toBe(true); + expect(result.errors.some((e) => e.toLowerCase().includes("clawback"))).toBe(true); + }); +}); diff --git a/test/horizonPaginator.test.ts b/test/horizonPaginator.test.ts new file mode 100644 index 0000000..665690e --- /dev/null +++ b/test/horizonPaginator.test.ts @@ -0,0 +1,155 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + InMemoryCursorStore, + setDefaultCursorStore, + getDefaultCursorStore, + buildCursorKey, +} from "../src/cursorTracker.js"; +import { paginate, collectAll } from "../src/horizonPaginator.js"; +import type { CollectionPage } from "../src/types.js"; + +describe("InMemoryCursorStore", () => { + let store: InMemoryCursorStore; + + beforeEach(() => { + store = new InMemoryCursorStore(); + }); + + it("saves and loads cursors", async () => { + await store.save("key1", "cursor-abc"); + expect(await store.load("key1")).toBe("cursor-abc"); + }); + + it("returns null for unknown keys", async () => { + expect(await store.load("nonexistent")).toBeNull(); + }); + + it("deletes cursors", async () => { + await store.save("key1", "cursor-abc"); + await store.delete("key1"); + expect(await store.load("key1")).toBeNull(); + }); + + it("clears all cursors", async () => { + await store.save("a", "1"); + await store.save("b", "2"); + store.clear(); + expect(await store.load("a")).toBeNull(); + expect(await store.load("b")).toBeNull(); + }); +}); + +describe("cursorTracker", () => { + it("setDefaultCursorStore and getDefaultCursorStore", () => { + const store = new InMemoryCursorStore(); + setDefaultCursorStore(store); + expect(getDefaultCursorStore()).toBe(store); + }); + + it("buildCursorKey creates namespaced keys", () => { + expect(buildCursorKey("horizon", "last")).toBe("horizon:last"); + expect(buildCursorKey("payments", "cursor")).toBe("payments:cursor"); + }); +}); + +describe("paginate", () => { + function makePage(records: T[], nextPage: CollectionPage | null): CollectionPage { + return { + records, + next: vi.fn().mockResolvedValue(nextPage), + }; + } + + it("yields all records from a single page", async () => { + const page = makePage( + [{ id: "1" }, { id: "2" }, { id: "3" }], + null, + ); + + const results = []; + for await (const record of paginate(page)) { + results.push(record); + } + + expect(results).toHaveLength(3); + expect(results.map((r: Record) => r.id)).toEqual(["1", "2", "3"]); + }); + + it("walks multiple pages transparently", async () => { + const page3 = makePage([{ id: "7" }, { id: "8" }], null); + const page2 = makePage([{ id: "4" }, { id: "5" }, { id: "6" }], page3); + const page1 = makePage([{ id: "1" }, { id: "2" }, { id: "3" }], page2); + + const results = []; + for await (const record of paginate(page1)) { + results.push(record); + } + + expect(results.map((r: Record) => r.id)).toEqual([ + "1", "2", "3", "4", "5", "6", "7", "8", + ]); + expect(page1.next).toHaveBeenCalled(); + expect(page2.next).toHaveBeenCalled(); + // page3's next() is called (returns null) — that's expected behavior + }); + + it("respects maxRecords limit", async () => { + const page2 = makePage([{ id: "4" }, { id: "5" }], null); + const page1 = makePage([{ id: "1" }, { id: "2" }, { id: "3" }], page2); + + const results = []; + for await (const record of paginate(page1, { maxRecords: 4 })) { + results.push(record); + } + + expect(results).toHaveLength(4); + expect(results.map((r: Record) => r.id)).toEqual(["1", "2", "3", "4"]); + }); + + it("handles empty pages", async () => { + const page = makePage([], null); + const results = []; + for await (const record of paginate(page)) { + results.push(record); + } + expect(results).toHaveLength(0); + }); + + it("handles page that becomes null", async () => { + const page1 = makePage([{ id: "1" }], null as unknown as CollectionPage); + // Override next to return null + page1.next = vi.fn().mockResolvedValue(null); + + const results = []; + for await (const record of paginate(page1)) { + results.push(record); + } + expect(results).toHaveLength(1); + }); +}); + +describe("collectAll", () => { + function makePage(records: T[], nextPage: CollectionPage | null): CollectionPage { + return { + records, + next: vi.fn().mockResolvedValue(nextPage), + }; + } + + it("collects all records into an array", async () => { + const page2 = makePage([{ id: "3" }, { id: "4" }], null); + const page1 = makePage([{ id: "1" }, { id: "2" }], page2); + + const results = await collectAll(page1); + expect(results).toHaveLength(4); + expect(results.map((r: Record) => r.id)).toEqual(["1", "2", "3", "4"]); + }); + + it("respects maxRecords with collectAll", async () => { + const page2 = makePage([{ id: "4" }, { id: "5" }, { id: "6" }], null); + const page1 = makePage([{ id: "1" }, { id: "2" }, { id: "3" }], page2); + + const results = await collectAll(page1, { maxRecords: 5 }); + expect(results).toHaveLength(5); + }); +}); diff --git a/test/memoBuilder.test.ts b/test/memoBuilder.test.ts new file mode 100644 index 0000000..e879bd7 --- /dev/null +++ b/test/memoBuilder.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect } from "vitest"; +import { Memo } from "@stellar/stellar-sdk"; +import { + buildMemo, + buildHashMemo, + buildIdMemo, + parseMemo, + isStellarSplitMemo, + MEMO_PREFIX, +} from "../src/memoBuilder.js"; +import type { ParsedMemo, SplitConfig } from "../src/types.js"; + +const CONFIG: SplitConfig = { version: 1 }; +const PAYER = "GABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZABCD"; +const INVOICE_ID = "42"; + +describe("buildMemo", () => { + it("builds a text memo with the canonical SS: prefix", () => { + const memo = buildMemo(INVOICE_ID, CONFIG, PAYER); + expect(memo.type).toBe("text"); + const text = memo.value as string; + expect(text).toMatch(/^SS:v1:42:[A-Z0-9]{8}$/); + }); + + it("fits within the 28-byte text memo limit", () => { + const memo = buildMemo(INVOICE_ID, CONFIG, PAYER); + const bytes = new TextEncoder().encode(memo.value as string); + expect(bytes.length).toBeLessThanOrEqual(28); + }); + + it("includes the payer address suffix", () => { + const memo = buildMemo(INVOICE_ID, CONFIG, PAYER); + const text = memo.value as string; + // Last 8 chars of PAYER + const expectedSuffix = PAYER.slice(-8); + expect(text).toContain(expectedSuffix); + }); + + it("encodes the split config version", () => { + const memo1 = buildMemo(INVOICE_ID, { version: 1 }, PAYER); + const memo2 = buildMemo(INVOICE_ID, { version: 3 }, PAYER); + expect((memo1.value as string)).toContain("v1:"); + expect((memo2.value as string)).toContain("v3:"); + }); + + it("throws for payer addresses shorter than 8 chars", () => { + expect(() => buildMemo(INVOICE_ID, CONFIG, "GABC")).toThrow( + "Payer address must be at least 8 characters", + ); + }); + + it("throws when the memo exceeds 28 bytes", () => { + // Use a very long invoice ID to force overflow + const longId = "123456789012345678901234567890"; + expect(() => buildMemo(longId, CONFIG, PAYER)).toThrow( + "Memo exceeds 28 bytes", + ); + }); +}); + +describe("buildHashMemo", () => { + it("returns a hash-type memo", () => { + const memo = buildHashMemo(INVOICE_ID, CONFIG, PAYER); + expect(memo.type).toBe("hash"); + }); +}); + +describe("buildIdMemo", () => { + it("returns an id-type memo for numeric invoice IDs", () => { + const memo = buildIdMemo("42"); + expect(memo.type).toBe("id"); + }); + + it("accepts number input", () => { + const memo = buildIdMemo(42); + expect(memo.type).toBe("id"); + }); +}); + +describe("parseMemo", () => { + it("round-trips: parseMemo(buildMemo(...)) returns original fields", () => { + const built = buildMemo(INVOICE_ID, CONFIG, PAYER); + const parsed = parseMemo(built); + expect(parsed.invoiceId).toBe(INVOICE_ID); + expect(parsed.version).toBe(CONFIG.version); + expect(parsed.payerId).toBe(PAYER.slice(-8)); + }); + + it("throws for non-text memos", () => { + const idMemo = buildIdMemo("42"); + expect(() => parseMemo(idMemo)).toThrow( + 'Cannot parse memo of type "id"', + ); + }); + + it("throws for memos without the SS: prefix", () => { + const memo = Memo.text("random text"); + expect(() => parseMemo(memo)).toThrow( + `Memo does not start with expected prefix "${MEMO_PREFIX}"`, + ); + }); + + it("throws for memos with invalid version format", () => { + const memo = Memo.text("SS:abc:42:ABCDEFGH"); + expect(() => parseMemo(memo)).toThrow("Invalid memo format"); + }); + + it("throws for memos with empty invoice ID", () => { + const memo = Memo.text("SS:v1::ABCDEFGH"); + expect(() => parseMemo(memo)).toThrow("empty invoice ID"); + }); + + it("throws for memos with wrong payer suffix length", () => { + const memo = Memo.text("SS:v1:42:ABC"); + expect(() => parseMemo(memo)).toThrow("payer suffix must be 8 characters"); + }); +}); + +describe("isStellarSplitMemo", () => { + it("returns true for valid split memos", () => { + const memo = buildMemo(INVOICE_ID, CONFIG, PAYER); + expect(isStellarSplitMemo(memo)).toBe(true); + }); + + it("returns false for memos without the prefix", () => { + const memo = Memo.text("hello world"); + expect(isStellarSplitMemo(memo)).toBe(false); + }); + + it("returns false for non-text memos", () => { + expect(isStellarSplitMemo(Memo.id("1"))).toBe(false); + expect(isStellarSplitMemo(Memo.none())).toBe(false); + }); +}); diff --git a/test/sep24Handler.test.ts b/test/sep24Handler.test.ts new file mode 100644 index 0000000..be923b9 --- /dev/null +++ b/test/sep24Handler.test.ts @@ -0,0 +1,224 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { Sep24Handler, resolveAnchorTransferServer } from "../src/sep/sep24Handler.js"; +import type { Sep24TransactionRecord } from "../src/types.js"; + +// Mock StellarTomlResolver +vi.mock("@stellar/stellar-sdk", () => ({ + StellarTomlResolver: { + resolve: vi.fn(), + }, +})); + +import { StellarTomlResolver } from "@stellar/stellar-sdk"; + +describe("Sep24Handler", () => { + let handler: Sep24Handler; + + beforeEach(() => { + handler = new Sep24Handler(); + vi.useFakeTimers(); + }); + + afterEach(() => { + handler.destroy(); + vi.useRealTimers(); + }); + + const mockInitResponse = { + id: "txn-abc123", + url: "https://anchor.example.com/interactive/abc123", + interactive_url: "https://anchor.example.com/interactive/abc123", + }; + + const mockStatusResponse = (status: string) => ({ + transaction: { + id: "txn-abc123", + status, + stellar_transaction_id: null, + }, + }); + + function mockFetch(responses: Array<{ status: number; body: unknown }>) { + let callIndex = 0; + globalThis.fetch = vi.fn().mockImplementation(async () => { + const resp = responses[callIndex++ % responses.length]!; + return { + ok: resp.status < 400, + status: resp.status, + json: async () => resp.body, + text: async () => JSON.stringify(resp.body), + }; + }); + } + + it("throws when SEP-24 initiation fails", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 400, + text: async () => "Bad Request", + }); + + await expect( + handler.init({ + anchorUrl: "https://anchor.example.com", + jwt: "test-jwt", + kind: "deposit", + assetCode: "USDC", + assetIssuer: "G...ISSUER", + amount: 100_0000000n, + account: "G...USER", + pollIntervalMs: 100, + }), + ).rejects.toThrow("SEP-24 initiation failed"); + }); + + it("emits sep24StatusChanged events as status progresses", async () => { + const events: Sep24TransactionRecord[] = []; + handler.on("sep24StatusChanged", (event) => { + events.push(event.transaction); + }); + + mockFetch([ + { status: 200, body: mockInitResponse }, + { status: 200, body: mockStatusResponse("pending_user_transfer_start") }, + { status: 200, body: mockStatusResponse("pending_anchor") }, + { status: 200, body: mockStatusResponse("completed") }, + ]); + + const txn = await handler.init({ + anchorUrl: "https://anchor.example.com", + jwt: "test-jwt", + kind: "deposit", + assetCode: "USDC", + assetIssuer: "G...ISSUER", + amount: 100_0000000n, + account: "G...USER", + pollIntervalMs: 100, + }); + + expect(txn.status).toBe("incomplete"); + expect(txn.id).toBe("txn-abc123"); + expect(txn.interactiveUrl).toBe("https://anchor.example.com/interactive/abc123"); + + // Advance timers to trigger polls + await vi.advanceTimersByTimeAsync(200); + expect(events.length).toBeGreaterThanOrEqual(1); + + await vi.advanceTimersByTimeAsync(200); + + // On completed, polling should stop + const finalTxn = handler.getTransaction(); + expect(finalTxn?.status).toBe("completed"); + + // Should stop polling after completed + await vi.advanceTimersByTimeAsync(500); + // No more events after terminal status + const countAfterCompleted = events.length; + await vi.advanceTimersByTimeAsync(500); + expect(events.length).toBe(countAfterCompleted); + }); + + it("getTransaction returns null before init", () => { + expect(handler.getTransaction()).toBeNull(); + }); + + it("getTransaction returns the current record after init", async () => { + mockFetch([ + { status: 200, body: mockInitResponse }, + { status: 200, body: mockStatusResponse("pending_anchor") }, + ]); + + await handler.init({ + anchorUrl: "https://anchor.example.com", + jwt: "test-jwt", + kind: "withdrawal", + assetCode: "USDC", + assetIssuer: "G...ISSUER", + amount: 50_0000000n, + account: "G...USER", + pollIntervalMs: 100, + }); + + const txn = handler.getTransaction(); + expect(txn).not.toBeNull(); + expect(txn!.kind).toBe("withdrawal"); + expect(txn!.amount).toBe(50_0000000n); + }); + + it("handles error status transitions", async () => { + mockFetch([ + { status: 200, body: mockInitResponse }, + { status: 200, body: mockStatusResponse("pending_anchor") }, + { status: 200, body: { transaction: { id: "txn-abc123", status: "error", message: "KYC failed" } } }, + ]); + + handler.on("sep24StatusChanged", (event) => { + if (event.transaction.status === "error") { + expect(event.transaction.errorMessage).toBe("KYC failed"); + } + }); + + await handler.init({ + anchorUrl: "https://anchor.example.com", + jwt: "test-jwt", + kind: "deposit", + assetCode: "USDC", + assetIssuer: "G...ISSUER", + amount: 100_0000000n, + account: "G...USER", + pollIntervalMs: 100, + }); + + await vi.advanceTimersByTimeAsync(300); + expect(handler.getTransaction()?.status).toBe("error"); + }); + + it("destroy stops polling and clears transaction", async () => { + mockFetch([ + { status: 200, body: mockInitResponse }, + { status: 200, body: mockStatusResponse("pending_anchor") }, + ]); + + await handler.init({ + anchorUrl: "https://anchor.example.com", + jwt: "test-jwt", + kind: "deposit", + assetCode: "USDC", + assetIssuer: "G...ISSUER", + amount: 100_0000000n, + account: "G...USER", + pollIntervalMs: 100, + }); + + expect(handler.getTransaction()).not.toBeNull(); + handler.destroy(); + expect(handler.getTransaction()).toBeNull(); + }); +}); + +describe("resolveAnchorTransferServer", () => { + it("returns TRANSFER_SERVER URL from stellar.toml", async () => { + (StellarTomlResolver.resolve as ReturnType).mockResolvedValue({ + TRANSFER_SERVER: "https://api.anchor.example.com/sep24", + }); + + const result = await resolveAnchorTransferServer("anchor.example.com"); + expect(result).toBe("https://api.anchor.example.com/sep24"); + }); + + it("returns null when TRANSFER_SERVER is missing", async () => { + (StellarTomlResolver.resolve as ReturnType).mockResolvedValue({}); + + const result = await resolveAnchorTransferServer("anchor.example.com"); + expect(result).toBeNull(); + }); + + it("returns null on resolve failure", async () => { + (StellarTomlResolver.resolve as ReturnType).mockRejectedValue( + new Error("Network error"), + ); + + const result = await resolveAnchorTransferServer("invalid.example.com"); + expect(result).toBeNull(); + }); +});