diff --git a/src/chains/stellar/federation.ts b/src/chains/stellar/federation.ts new file mode 100644 index 0000000..a6cabc2 --- /dev/null +++ b/src/chains/stellar/federation.ts @@ -0,0 +1,370 @@ +import { StrKey } from '@stellar/stellar-sdk'; + +/** + * Default cache TTL (1 hour) for resolved federation addresses. + * + * @see {@link setFederationDefaultTtl} + */ +export const DEFAULT_FEDERATION_TTL_MS = 60 * 60 * 1000; + +/** Default per-request timeout in milliseconds. */ +const DEFAULT_TIMEOUT_MS = 10_000; + +/** SEP-0001 well-known stellar.toml path. */ +const STELLAR_TOML_PATH = '/.well-known/stellar.toml'; + +/** + * Validates the structural form of a federation address (`name*domain.tld`). + * + * The Stellar Federation spec (SEP-0002) allows alphanumerics, dots, dashes, + * underscores, and `+` in the `name`; the domain must contain at least one dot. + */ +const FEDERATION_REGEX = /^[A-Za-z0-9._+\-]+\*[A-Za-z0-9.\-]+\.[A-Za-z]{2,}$/; + +/** Supported Stellar memo types as per SEP-0002. */ +export type FederationMemoType = 'text' | 'id' | 'hash'; + +/** Memo specified by a federation server for a resolved address. */ +export interface FederationMemo { + /** Memo type — one of `text`, `id`, or `hash`. */ + type: FederationMemoType; + /** Memo value (text payload, integer string, or hex hash). */ + value: string; +} + +/** Typed result returned by {@link resolveStellarFederation}. */ +export interface FederationResolution { + /** Stellar account ID (G...) the federation address resolves to. */ + accountId: string; + /** The federation address as reported by the server (or the original input). */ + stellarAddress: string; + /** Optional memo specified by the federation server. */ + memo?: FederationMemo; + /** Unix milliseconds when the resolution was produced. */ + resolvedAt: number; +} + +/** Discriminator for {@link FederationResolutionError} consumers. */ +export type FederationErrorCode = + | 'INVALID_ADDRESS' + | 'TOML_FETCH_FAILED' + | 'FEDERATION_SERVER_MISSING' + | 'FEDERATION_SERVER_FAILED' + | 'INVALID_RESPONSE' + | 'TIMEOUT' + | 'INSECURE_PROTOCOL'; + +/** + * Decoded error thrown by {@link resolveStellarFederation} whenever a lookup + * cannot be completed. The `code` field categorises the failure for + * programmatic handling; the `message` is a human-readable explanation. + */ +export class FederationResolutionError extends Error { + readonly code: FederationErrorCode; + + constructor(code: FederationErrorCode, message: string, cause?: unknown) { + super(message); + this.name = 'FederationResolutionError'; + this.code = code; + if (cause !== undefined) { + (this as { cause?: unknown }).cause = cause; + } + } +} + +/** Per-call configuration for {@link resolveStellarFederation}. */ +export interface ResolveFederationOptions { + /** + * Cache TTL in milliseconds for this resolution. Defaults to the value set + * by {@link setFederationDefaultTtl} (1 hour at module load). Use `0` to + * skip writing this entry to the cache. + */ + cacheTtlMs?: number; + /** Bypass any cached entry for this call (and skip writing the result). */ + noCache?: boolean; + /** Custom fetch implementation — useful for tests, proxies, or RN polyfills. */ + fetchImpl?: typeof fetch; + /** Per-request timeout in milliseconds. Default `10_000`. */ + timeoutMs?: number; + /** + * Allow http:// URLs. SEP-0001 requires HTTPS, so by default any + * `FEDERATION_SERVER` that uses http will be rejected. Only set this to + * `true` for testing against local servers. + */ + allowInsecureHttp?: boolean; +} + +interface CacheEntry { + expiresAt: number; + resolution: FederationResolution; +} + +const cache = new Map(); +let defaultTtlMs = DEFAULT_FEDERATION_TTL_MS; + +/** + * Resolve a Stellar federation address (SEP-0002, `name*domain.com`) into a + * Stellar account ID and optional memo. + * + * Resolution flow: + * 1. Validates the input shape. Already-encoded `G...` and `M...` account IDs + * are passed through unchanged. + * 2. Fetches `https:///.well-known/stellar.toml` (SEP-0001) and + * extracts `FEDERATION_SERVER`. + * 3. Queries `?q=
&type=name`. + * 4. Validates the response (account_id strkey, optional memo) and caches it. + * + * Results are cached in-memory by address for the configured TTL (default + * 1 hour). Call {@link clearFederationCache} to drop cached entries or + * {@link setFederationDefaultTtl} to change the default. + * + * @param input Federation address (`name*domain.com`) or a Stellar account + * ID (`G...` or `M...`), which is returned without a network + * round-trip. + * @param options Per-call cache / fetch / timeout overrides. + * @throws {FederationResolutionError} If lookup fails at any step. + * + * @example + * ```ts + * import { resolveStellarFederation } from '@wraith-protocol/sdk/chains/stellar'; + * + * const { accountId, memo } = await resolveStellarFederation('alice*example.com'); + * if (memo) console.log(`Send with memo ${memo.type}=${memo.value}`); + * ``` + */ +export async function resolveStellarFederation( + input: string, + options: ResolveFederationOptions = {}, +): Promise { + const address = typeof input === 'string' ? input.trim() : ''; + if (!address) { + throw new FederationResolutionError('INVALID_ADDRESS', 'federation address is empty'); + } + + if (isStellarAccountId(address)) { + return { + accountId: address, + stellarAddress: address, + resolvedAt: Date.now(), + }; + } + + if (!FEDERATION_REGEX.test(address)) { + throw new FederationResolutionError( + 'INVALID_ADDRESS', + `not a valid federation address: "${address}". Expected "name*domain.tld".`, + ); + } + + const noCache = options.noCache === true; + if (!noCache) { + const cached = readCache(address); + if (cached) return cached; + } + + const domain = address.split('*')[1]; + const resolution = await performResolution(address, domain, options); + + if (!noCache) { + const ttl = options.cacheTtlMs ?? defaultTtlMs; + if (ttl > 0) { + cache.set(address, { resolution, expiresAt: Date.now() + ttl }); + } + } + + return resolution; +} + +/** + * Clear cached federation resolutions. + * + * @param address Optional federation address to evict. When omitted, the + * entire cache is cleared. + */ +export function clearFederationCache(address?: string): void { + if (address) cache.delete(address); + else cache.clear(); +} + +/** + * Change the default TTL applied to newly cached federation resolutions. + * Existing entries keep their original expiry. + * + * @param ttlMs A non-negative finite number of milliseconds. `0` disables + * caching for subsequent resolutions that don't override the TTL. + */ +export function setFederationDefaultTtl(ttlMs: number): void { + if (!Number.isFinite(ttlMs) || ttlMs < 0) { + throw new Error('ttlMs must be a non-negative finite number'); + } + defaultTtlMs = ttlMs; +} + +/** Returns the current default cache TTL in milliseconds. */ +export function getFederationDefaultTtl(): number { + return defaultTtlMs; +} + +async function performResolution( + address: string, + domain: string, + options: ResolveFederationOptions, +): Promise { + const tomlUrl = `https://${domain}${STELLAR_TOML_PATH}`; + const tomlText = await fetchText(tomlUrl, options, 'TOML_FETCH_FAILED'); + const federationServer = parseFederationServer(tomlText); + + if (!federationServer) { + throw new FederationResolutionError( + 'FEDERATION_SERVER_MISSING', + `${domain} stellar.toml does not define FEDERATION_SERVER`, + ); + } + if (!/^https?:\/\//i.test(federationServer)) { + throw new FederationResolutionError( + 'INVALID_RESPONSE', + `FEDERATION_SERVER is not a valid URL: ${federationServer}`, + ); + } + if (/^http:\/\//i.test(federationServer) && options.allowInsecureHttp !== true) { + throw new FederationResolutionError( + 'INSECURE_PROTOCOL', + `FEDERATION_SERVER must use HTTPS: ${federationServer}`, + ); + } + + const sep = federationServer.includes('?') ? '&' : '?'; + const queryUrl = `${federationServer}${sep}q=${encodeURIComponent(address)}&type=name`; + const text = await fetchText(queryUrl, options, 'FEDERATION_SERVER_FAILED'); + + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch (err) { + throw new FederationResolutionError( + 'INVALID_RESPONSE', + `federation server response is not JSON: ${text.slice(0, 120)}`, + err, + ); + } + + return shapeResponse(parsed, address); +} + +function shapeResponse(parsed: unknown, address: string): FederationResolution { + if (!parsed || typeof parsed !== 'object') { + throw new FederationResolutionError( + 'INVALID_RESPONSE', + 'federation response is not a JSON object', + ); + } + const obj = parsed as Record; + + if (typeof obj.detail === 'string' && typeof obj.account_id !== 'string') { + throw new FederationResolutionError( + 'INVALID_RESPONSE', + `federation server error: ${obj.detail}`, + ); + } + + const accountId = obj.account_id; + if (typeof accountId !== 'string' || !isStellarAccountId(accountId)) { + throw new FederationResolutionError( + 'INVALID_RESPONSE', + `federation response missing or invalid account_id: ${String(accountId)}`, + ); + } + + let memo: FederationMemo | undefined; + if (obj.memo_type !== undefined || obj.memo !== undefined) { + const type = obj.memo_type; + const value = obj.memo; + if (typeof type !== 'string' || (value !== undefined && typeof value !== 'string')) { + throw new FederationResolutionError( + 'INVALID_RESPONSE', + 'memo_type must be a string and memo must be a string when provided', + ); + } + if (type !== 'text' && type !== 'id' && type !== 'hash') { + throw new FederationResolutionError( + 'INVALID_RESPONSE', + `unsupported memo_type "${type}". Expected "text", "id", or "hash".`, + ); + } + if (typeof value === 'string') { + memo = { type, value }; + } + } + + const reported = typeof obj.stellar_address === 'string' ? obj.stellar_address : address; + return { accountId, stellarAddress: reported, memo, resolvedAt: Date.now() }; +} + +async function fetchText( + url: string, + options: ResolveFederationOptions, + failureCode: FederationErrorCode, +): Promise { + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const fetchImpl = options.fetchImpl ?? globalThis.fetch; + if (typeof fetchImpl !== 'function') { + throw new FederationResolutionError( + failureCode, + 'no global fetch available — pass options.fetchImpl', + ); + } + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const res = await fetchImpl(url, { + headers: { Accept: 'application/json, text/plain, */*' }, + signal: controller.signal, + }); + if (!res.ok) { + throw new FederationResolutionError( + failureCode, + `request failed: ${res.status} ${res.statusText || ''} (${url})`.trim(), + ); + } + return await res.text(); + } catch (err) { + if (err instanceof FederationResolutionError) throw err; + const name = (err as { name?: string } | null)?.name; + if (name === 'AbortError' || name === 'TimeoutError') { + throw new FederationResolutionError( + 'TIMEOUT', + `request timed out after ${timeoutMs} ms (${url})`, + err, + ); + } + const message = err instanceof Error ? err.message : String(err); + throw new FederationResolutionError(failureCode, `request error for ${url}: ${message}`, err); + } finally { + clearTimeout(timer); + } +} + +function parseFederationServer(toml: string): string | null { + const match = toml.match(/^\s*FEDERATION_SERVER\s*=\s*(?:"([^"]+)"|'([^']+)')/m); + if (!match) return null; + return match[1] ?? match[2] ?? null; +} + +function readCache(address: string): FederationResolution | null { + const entry = cache.get(address); + if (!entry) return null; + if (entry.expiresAt <= Date.now()) { + cache.delete(address); + return null; + } + return entry.resolution; +} + +function isStellarAccountId(value: string): boolean { + if (StrKey.isValidEd25519PublicKey(value)) return true; + const muxed = (StrKey as unknown as { isValidMed25519PublicKey?: (v: string) => boolean }) + .isValidMed25519PublicKey; + if (typeof muxed === 'function' && muxed(value)) return true; + return false; +} diff --git a/src/chains/stellar/index.ts b/src/chains/stellar/index.ts index 553de71..f0e517c 100644 --- a/src/chains/stellar/index.ts +++ b/src/chains/stellar/index.ts @@ -83,3 +83,19 @@ export type { export { buildStellarSwapAndStealth } from './swap'; export type { BuildStellarSwapAndStealthOptions, SwapAndStealthResult } from './swap'; + +export { + resolveStellarFederation, + clearFederationCache, + setFederationDefaultTtl, + getFederationDefaultTtl, + FederationResolutionError, + DEFAULT_FEDERATION_TTL_MS, +} from './federation'; +export type { + FederationResolution, + FederationMemo, + FederationMemoType, + FederationErrorCode, + ResolveFederationOptions, +} from './federation'; diff --git a/test/chains/stellar/federation.test.ts b/test/chains/stellar/federation.test.ts new file mode 100644 index 0000000..c53dd99 --- /dev/null +++ b/test/chains/stellar/federation.test.ts @@ -0,0 +1,346 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Keypair } from '@stellar/stellar-sdk'; +import { + FederationResolutionError, + clearFederationCache, + getFederationDefaultTtl, + resolveStellarFederation, + setFederationDefaultTtl, +} from '../../../src/chains/stellar/federation'; + +type FetchInput = { url: string; init?: RequestInit }; + +function textResponse(body: string, init: { ok?: boolean; status?: number } = {}): Response { + return { + ok: init.ok ?? true, + status: init.status ?? 200, + statusText: '', + text: async () => body, + } as unknown as Response; +} + +function jsonResponse(body: unknown, init: { ok?: boolean; status?: number } = {}): Response { + return textResponse(JSON.stringify(body), init); +} + +function mockFetch(handler: (call: FetchInput) => Response | Promise) { + const calls: FetchInput[] = []; + const impl = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === 'string' ? input : (input as URL).toString(); + const call = { url, init }; + calls.push(call); + return handler(call); + }); + return { impl, calls }; +} + +const tomlBody = (federationUrl: string) => ` +NETWORK_PASSPHRASE = "Test SDF Network ; September 2015" +FEDERATION_SERVER = "${federationUrl}" +WEB_AUTH_ENDPOINT = "https://example.com/auth" +`; + +describe('resolveStellarFederation', () => { + const recipient = Keypair.random(); + const federationUrl = 'https://federation.example.com/federation'; + + beforeEach(() => { + clearFederationCache(); + setFederationDefaultTtl(60 * 60 * 1000); + }); + + afterEach(() => { + vi.restoreAllMocks(); + clearFederationCache(); + }); + + it('resolves a federation address through stellar.toml + federation server', async () => { + const { impl, calls } = mockFetch(({ url }) => { + if (url.endsWith('/.well-known/stellar.toml')) { + return textResponse(tomlBody(federationUrl)); + } + if (url.startsWith(federationUrl)) { + return jsonResponse({ + stellar_address: 'alice*example.com', + account_id: recipient.publicKey(), + memo_type: 'text', + memo: 'invoice-42', + }); + } + throw new Error(`unexpected url ${url}`); + }); + + const result = await resolveStellarFederation('alice*example.com', { fetchImpl: impl }); + + expect(result.accountId).toBe(recipient.publicKey()); + expect(result.memo).toEqual({ type: 'text', value: 'invoice-42' }); + expect(result.stellarAddress).toBe('alice*example.com'); + expect(result.resolvedAt).toBeTypeOf('number'); + + expect(calls).toHaveLength(2); + expect(calls[0].url).toBe('https://example.com/.well-known/stellar.toml'); + expect(calls[1].url).toBe( + `${federationUrl}?q=${encodeURIComponent('alice*example.com')}&type=name`, + ); + }); + + it('returns a result without memo when none is provided', async () => { + const { impl } = mockFetch(({ url }) => { + if (url.endsWith('/.well-known/stellar.toml')) return textResponse(tomlBody(federationUrl)); + return jsonResponse({ + stellar_address: 'bob*example.com', + account_id: recipient.publicKey(), + }); + }); + + const result = await resolveStellarFederation('bob*example.com', { fetchImpl: impl }); + + expect(result.accountId).toBe(recipient.publicKey()); + expect(result.memo).toBeUndefined(); + }); + + it('caches resolutions for the configured TTL', async () => { + const { impl, calls } = mockFetch(({ url }) => { + if (url.endsWith('/.well-known/stellar.toml')) return textResponse(tomlBody(federationUrl)); + return jsonResponse({ account_id: recipient.publicKey() }); + }); + + await resolveStellarFederation('carol*example.com', { fetchImpl: impl }); + await resolveStellarFederation('carol*example.com', { fetchImpl: impl }); + await resolveStellarFederation('carol*example.com', { fetchImpl: impl }); + + expect(calls).toHaveLength(2); + }); + + it('skips the cache when noCache is set', async () => { + const { impl, calls } = mockFetch(({ url }) => { + if (url.endsWith('/.well-known/stellar.toml')) return textResponse(tomlBody(federationUrl)); + return jsonResponse({ account_id: recipient.publicKey() }); + }); + + await resolveStellarFederation('dan*example.com', { fetchImpl: impl }); + await resolveStellarFederation('dan*example.com', { fetchImpl: impl, noCache: true }); + await resolveStellarFederation('dan*example.com', { fetchImpl: impl, noCache: true }); + + expect(calls).toHaveLength(6); + }); + + it('does not cache when cacheTtlMs is 0', async () => { + const { impl, calls } = mockFetch(({ url }) => { + if (url.endsWith('/.well-known/stellar.toml')) return textResponse(tomlBody(federationUrl)); + return jsonResponse({ account_id: recipient.publicKey() }); + }); + + await resolveStellarFederation('eve*example.com', { fetchImpl: impl, cacheTtlMs: 0 }); + await resolveStellarFederation('eve*example.com', { fetchImpl: impl, cacheTtlMs: 0 }); + + expect(calls).toHaveLength(4); + }); + + it('refetches once an entry has expired', async () => { + setFederationDefaultTtl(50); + const { impl, calls } = mockFetch(({ url }) => { + if (url.endsWith('/.well-known/stellar.toml')) return textResponse(tomlBody(federationUrl)); + return jsonResponse({ account_id: recipient.publicKey() }); + }); + + await resolveStellarFederation('frank*example.com', { fetchImpl: impl }); + await new Promise((resolve) => setTimeout(resolve, 80)); + await resolveStellarFederation('frank*example.com', { fetchImpl: impl }); + + expect(calls).toHaveLength(4); + }); + + it('passes through a valid G... account ID without hitting the network', async () => { + const { impl, calls } = mockFetch(() => textResponse('should not be called', { ok: false })); + + const result = await resolveStellarFederation(recipient.publicKey(), { fetchImpl: impl }); + + expect(result.accountId).toBe(recipient.publicKey()); + expect(result.stellarAddress).toBe(recipient.publicKey()); + expect(result.memo).toBeUndefined(); + expect(calls).toHaveLength(0); + }); + + it('rejects malformed federation addresses with INVALID_ADDRESS', async () => { + await expect( + resolveStellarFederation('not-a-federation-address', { fetchImpl: vi.fn() }), + ).rejects.toMatchObject({ + name: 'FederationResolutionError', + code: 'INVALID_ADDRESS', + }); + + await expect( + resolveStellarFederation('alice@example.com', { fetchImpl: vi.fn() }), + ).rejects.toMatchObject({ code: 'INVALID_ADDRESS' }); + + await expect( + resolveStellarFederation('alice*nodot', { fetchImpl: vi.fn() }), + ).rejects.toMatchObject({ code: 'INVALID_ADDRESS' }); + + await expect(resolveStellarFederation('', { fetchImpl: vi.fn() })).rejects.toMatchObject({ + code: 'INVALID_ADDRESS', + }); + }); + + it('throws TOML_FETCH_FAILED on stellar.toml HTTP errors', async () => { + const { impl } = mockFetch(() => textResponse('not found', { ok: false, status: 404 })); + + await expect( + resolveStellarFederation('gina*example.com', { fetchImpl: impl }), + ).rejects.toMatchObject({ code: 'TOML_FETCH_FAILED' }); + }); + + it('throws FEDERATION_SERVER_MISSING when stellar.toml has no FEDERATION_SERVER', async () => { + const { impl } = mockFetch(({ url }) => { + if (url.endsWith('/.well-known/stellar.toml')) { + return textResponse('NETWORK_PASSPHRASE = "Test SDF Network ; September 2015"'); + } + throw new Error('unreachable'); + }); + + await expect( + resolveStellarFederation('harry*example.com', { fetchImpl: impl }), + ).rejects.toMatchObject({ code: 'FEDERATION_SERVER_MISSING' }); + }); + + it('rejects http:// federation servers unless allowInsecureHttp is set', async () => { + const tomlInsecure = tomlBody('http://insecure.example.com/federation'); + const { impl } = mockFetch(({ url }) => { + if (url.endsWith('/.well-known/stellar.toml')) return textResponse(tomlInsecure); + return jsonResponse({ account_id: recipient.publicKey() }); + }); + + await expect( + resolveStellarFederation('isaac*example.com', { fetchImpl: impl }), + ).rejects.toMatchObject({ code: 'INSECURE_PROTOCOL' }); + + clearFederationCache(); + const allowed = await resolveStellarFederation('isaac*example.com', { + fetchImpl: impl, + allowInsecureHttp: true, + }); + expect(allowed.accountId).toBe(recipient.publicKey()); + }); + + it('throws FEDERATION_SERVER_FAILED on federation server HTTP errors', async () => { + const { impl } = mockFetch(({ url }) => { + if (url.endsWith('/.well-known/stellar.toml')) return textResponse(tomlBody(federationUrl)); + return textResponse('forbidden', { ok: false, status: 403 }); + }); + + await expect( + resolveStellarFederation('jess*example.com', { fetchImpl: impl }), + ).rejects.toMatchObject({ code: 'FEDERATION_SERVER_FAILED' }); + }); + + it('throws INVALID_RESPONSE when account_id is missing', async () => { + const { impl } = mockFetch(({ url }) => { + if (url.endsWith('/.well-known/stellar.toml')) return textResponse(tomlBody(federationUrl)); + return jsonResponse({ stellar_address: 'kev*example.com' }); + }); + + await expect( + resolveStellarFederation('kev*example.com', { fetchImpl: impl }), + ).rejects.toMatchObject({ code: 'INVALID_RESPONSE' }); + }); + + it('throws INVALID_RESPONSE when account_id is not a Stellar StrKey', async () => { + const { impl } = mockFetch(({ url }) => { + if (url.endsWith('/.well-known/stellar.toml')) return textResponse(tomlBody(federationUrl)); + return jsonResponse({ account_id: 'not-a-strkey' }); + }); + + await expect( + resolveStellarFederation('luke*example.com', { fetchImpl: impl }), + ).rejects.toMatchObject({ code: 'INVALID_RESPONSE' }); + }); + + it('surfaces federation server error bodies via INVALID_RESPONSE', async () => { + const { impl } = mockFetch(({ url }) => { + if (url.endsWith('/.well-known/stellar.toml')) return textResponse(tomlBody(federationUrl)); + return jsonResponse({ detail: 'unknown user' }); + }); + + await expect( + resolveStellarFederation('mia*example.com', { fetchImpl: impl }), + ).rejects.toMatchObject({ + code: 'INVALID_RESPONSE', + message: expect.stringMatching(/unknown user/), + }); + }); + + it('throws INVALID_RESPONSE on an unsupported memo_type', async () => { + const { impl } = mockFetch(({ url }) => { + if (url.endsWith('/.well-known/stellar.toml')) return textResponse(tomlBody(federationUrl)); + return jsonResponse({ + account_id: recipient.publicKey(), + memo_type: 'return', + memo: '12345', + }); + }); + + await expect( + resolveStellarFederation('nora*example.com', { fetchImpl: impl }), + ).rejects.toMatchObject({ code: 'INVALID_RESPONSE' }); + }); + + it('honors the per-request timeout', async () => { + const impl = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + return await new Promise((_, reject) => { + init?.signal?.addEventListener('abort', () => { + const err = new Error('aborted'); + err.name = 'AbortError'; + reject(err); + }); + }); + }); + + await expect( + resolveStellarFederation('owen*example.com', { fetchImpl: impl, timeoutMs: 15 }), + ).rejects.toMatchObject({ code: 'TIMEOUT' }); + }); + + it('exposes setFederationDefaultTtl / getFederationDefaultTtl', () => { + setFederationDefaultTtl(123); + expect(getFederationDefaultTtl()).toBe(123); + expect(() => setFederationDefaultTtl(-1)).toThrow(); + expect(() => setFederationDefaultTtl(Number.NaN)).toThrow(); + }); + + it('FederationResolutionError exposes code + cause', () => { + const inner = new Error('boom'); + const err = new FederationResolutionError('TIMEOUT', 'fetch timed out', inner); + expect(err.name).toBe('FederationResolutionError'); + expect(err.code).toBe('TIMEOUT'); + expect((err as { cause?: unknown }).cause).toBe(inner); + }); +}); + +// --------------------------------------------------------------------------- +// Opt-in integration test against a real federation server. +// Set FEDERATION_INTEGRATION=1 and FEDERATION_ADDRESS= to run. +// --------------------------------------------------------------------------- + +const SKIP_INTEGRATION = process.env['FEDERATION_INTEGRATION'] !== '1'; + +describe('resolveStellarFederation integration', { skip: SKIP_INTEGRATION }, () => { + it('resolves a configured live federation address', async () => { + const address = process.env['FEDERATION_ADDRESS']; + if (!address) throw new Error('FEDERATION_ADDRESS is required when FEDERATION_INTEGRATION=1'); + + clearFederationCache(); + const result = await resolveStellarFederation(address, { timeoutMs: 15_000 }); + + expect(result.accountId).toMatch(/^G[A-Z0-9]{55}$/); + expect(result.stellarAddress).toBeTypeOf('string'); + if (result.memo) { + expect(['text', 'id', 'hash']).toContain(result.memo.type); + } + + const expectedAccount = process.env['FEDERATION_EXPECTED_ACCOUNT']; + if (expectedAccount) { + expect(result.accountId).toBe(expectedAccount); + } + }, 30_000); +});