// SPDX-FileCopyrightText: © 2026 LEDGER SAS // SPDX-License-Identifier: Apache-2.0 import type { StellarCoinConfig, StellarContext } from '../config' import type { NetworkInfo, RawOperation, Signer } from '../types' import type { StellarDecodedResultXdr } from '../types/errors' import type { HorizonErrorBody } from './horizonErrorBody' import type { Operation, Page, Unit } from '@ledgerhq/coin-module-framework/api/types' import type { CacheRes } from '@ledgerhq/live-network/cache' import { parseCurrencyUnit } from '@ledgerhq/coin-module-framework/currencies' import { BlockNotFoundError, UnknownError } from '@ledgerhq/coin-module-framework/errors' import { makeLRUCache } from '@ledgerhq/live-network/cache' import { LedgerAPI4xx, LedgerAPI5xx, NetworkDown } from '@ledgerhq/live-network/errors' import { BASE_FEE, Horizon, MuxedAccount, NetworkError, Networks, NotFoundError, Transaction as StellarSdkTransaction, StrKey, xdr, } from '@stellar/stellar-sdk' import { BigNumber } from 'bignumber.js' import { patchHermesTypedArraysIfNeeded, unpatchHermesTypedArrays } from '../polyfill' import { NetworkCongestionLevel } from '../types' import { StellarBroadcastFailedError } from '../types/errors' import { documentationSummaryFromHorizonBody, getHorizonErrorBody } from './horizonErrorBody' import { throwHorizonLedgerOrOperationsError } from './horizonLedgerErrors' import { getReservedBalance, rawOperationsToOperations } from './serialization' const FALLBACK_BASE_FEE = 100 const TRESHOLD_LOW = 0.5 const TRESHOLD_MEDIUM = 0.75 const FETCH_LIMIT = 100 const STELLAR_TX_RESULT_CODES_DOC_URL = 'https://developers.stellar.org/docs/data/apis/horizon/api-reference/errors/result-codes/transactions' function decodeTransactionResultFields(resultXdrBase64: string | undefined): | { feeChargedStroops?: string resultXdrSwitchName?: string decodedResultXdr: StellarDecodedResultXdr } | undefined { if (!resultXdrBase64) { return undefined } try { const tr = xdr.TransactionResult.fromXDR(resultXdrBase64, 'base64') const feeChargedStroops = tr.feeCharged().toString() const resultXdrSwitchName = tr.result().switch().name const decodedResultXdr: StellarDecodedResultXdr = { feeChargedStroops, resultSwitch: resultXdrSwitchName, } return { feeChargedStroops, resultXdrSwitchName, decodedResultXdr } } catch { return { decodedResultXdr: { decodeFailed: true, rawResultXdrBase64: resultXdrBase64 }, } } } function makeBroadcastFailedError(body: HorizonErrorBody, cause: Error): Error { const extras = body.extras const horizonTransactionCode = extras?.result_codes?.transaction ?? '' const horizonOperationCodes = extras?.result_codes?.operations const documentationSummary = documentationSummaryFromHorizonBody(body) const decodedResult = decodeTransactionResultFields(extras?.result_xdr) const feeChargedStroops = decodedResult?.feeChargedStroops const resultXdrSwitchName = decodedResult?.resultXdrSwitchName const decodedResultXdr = decodedResult?.decodedResultXdr const envelopeXdr = extras?.envelope_xdr ?? '' const message = body.detail || body.title || `Transaction submission failed (${body.status ?? 'unknown'}).` return new StellarBroadcastFailedError( message, { documentationSummary, horizonTransactionCode, horizonOperationCodes, resultXdrSwitchName, feeChargedStroops, stellarDocUrl: STELLAR_TX_RESULT_CODES_DOC_URL, decodedResultXdr, envelopeXdr, }, { cause } ) } // Horizon client instance is cached to avoid costly rebuild at every request // Watch out: cache key is the URL, coin module can be instantiated several times with different URLs const servers = new Map() function getServer(config: StellarCoinConfig): Horizon.Server { const url = config.explorer.url let server = servers.get(url) if (server === undefined) { server = new Horizon.Server(url) servers.set(url, server) } return server } // Tracks request start times for response-duration logging. // WeakMap avoids mutating the feaxios request config (whose type doesn't include `metadata`). const requestStartTimes = new WeakMap() let interceptorsRegistered = false /** * Register logging and URL-fix interceptors on Horizon.AxiosClient. * Must be called once after a context is available (i.e. inside createApi). * * Logging is gated on context.config().enableNetworkLogs (driven by the ENABLE_NETWORK_LOGS env var). * Horizon.AxiosClient uses feaxios internally, so we type the callbacks via the inferred * InterceptorManager types rather than importing live-network's axios-typed interceptors. * * Note: Horizon.AxiosClient is a process-wide singleton, so interceptors are registered only once * and capture the context of the first {@link createApi} call. */ export function registerHorizonInterceptors(context: StellarContext): void { if (interceptorsRegistered) return interceptorsRegistered = true Horizon.AxiosClient.interceptors.request.use(async (config) => { let cfg: StellarCoinConfig try { cfg = await context.config() } catch { // Config unavailable (e.g. a transient failure in a custom accessor): don't break the request, // just skip logging and leave it untouched. return config } if (cfg.enableNetworkLogs) { const { baseURL, url, method = '', data } = config context.logger('network', `${method} ${baseURL ?? ''}${url}`, { data }) requestStartTimes.set(config, Date.now()) } return config }) Horizon.AxiosClient.interceptors.response.use(async (response) => { let cfg: StellarCoinConfig try { cfg = await context.config() } catch { // Config unavailable: skip logging and URL rewriting rather than failing every response. return response } if (cfg.enableNetworkLogs) { // response.config is typed as `any` in stellar-sdk's HttpClientResponse const startTime = requestStartTimes.get(response.config) ?? 0 requestStartTimes.delete(response.config) context.logger( 'network-success', `${response.status} ${response.config?.method ?? ''} ${response.config?.baseURL ?? ''}${response.config?.url ?? ''} (${(Date.now() - startTime).toFixed(0)}ms)`, { data: response.data } ) } // FIXME: workaround for the Stellar SDK not using the correct URL: the "next" URL // included in server responses points to the node itself instead of our reverse proxy... // (https://github.com/stellar/js-stellar-sdk/issues/637) const next_href = response?.data?._links?.next?.href if (next_href) { response.data._links.next.href = useConfigHostAndProtocol(cfg, next_href) } type EmbeddedRecord = { transaction?: { _links?: { ledger?: { href?: string } } } } response?.data?._embedded?.records?.forEach((r: EmbeddedRecord) => { const ledger = r.transaction?._links?.ledger if (ledger?.href) { ledger.href = useConfigHostAndProtocol(cfg, ledger.href) } }) return response }) } // It replaces the host and the protocol of the URL returned with the original ones. export function useConfigHostAndProtocol(config: StellarCoinConfig, url: string): string { const originalUrl = new URL(config.explorer.url) // URL.protocol setter silently fails when changing between special (https://) and // non-special (injected://) schemes, so reconstruct via string replacement instead. return url.replace(/^[^:]+:\/\/[^/]+/, `${originalUrl.protocol}//${originalUrl.host}`) } const getMinimumBalance = (unit: Unit, account: Horizon.ServerApi.AccountRecord): BigNumber => { return parseCurrencyUnit(unit, getReservedBalance(account).toString()) } export async function getAccountSpendableBalance( config: StellarCoinConfig, balance: BigNumber, account: Horizon.ServerApi.AccountRecord ): Promise { const minimumBalance = getMinimumBalance(config.unit, account) const { recommendedFee } = await fetchBaseFee(config) return BigNumber.max(balance.minus(minimumBalance).minus(recommendedFee), 0) } export async function fetchBaseFee(config: StellarCoinConfig): Promise<{ baseFee: number recommendedFee: number networkCongestionLevel: NetworkCongestionLevel }> { // For tests if (config.useStaticFees) { return { baseFee: 100, recommendedFee: 100, networkCongestionLevel: NetworkCongestionLevel.LOW, } } const baseFee = new BigNumber(BASE_FEE).toNumber() || FALLBACK_BASE_FEE let recommendedFee = baseFee let networkCongestionLevel = NetworkCongestionLevel.MEDIUM try { const feeStats = await getServer(config).feeStats() const ledgerCapacityUsage = feeStats.ledger_capacity_usage recommendedFee = new BigNumber(feeStats.fee_charged.mode).toNumber() if ( new BigNumber(ledgerCapacityUsage).toNumber() > TRESHOLD_LOW && new BigNumber(ledgerCapacityUsage).toNumber() <= TRESHOLD_MEDIUM ) { networkCongestionLevel = NetworkCongestionLevel.MEDIUM } else if (new BigNumber(ledgerCapacityUsage).toNumber() > TRESHOLD_MEDIUM) { networkCongestionLevel = NetworkCongestionLevel.HIGH } else { networkCongestionLevel = NetworkCongestionLevel.LOW } } catch { // do nothing, will use defaults } return { baseFee, recommendedFee, networkCongestionLevel, } } type NonNativeBalance = | Horizon.HorizonApi.BalanceLineAsset | Horizon.HorizonApi.BalanceLineLiquidityPool /** * Get all account-related data * * @async * @param {string} addr */ export async function fetchAccount( config: StellarCoinConfig, addr: string ): Promise<{ blockHeight: number balance: BigNumber spendableBalance: BigNumber assets: NonNativeBalance[] }> { let account: Horizon.ServerApi.AccountRecord | undefined let assets: NonNativeBalance[] = [] let balance = '0' try { account = await getServer(config).accounts().accountId(addr).call() balance = account.balances?.find((balance) => { return balance.asset_type === 'native' })?.balance || '0' // Getting all non-native (XLM) assets on the account assets = account.balances?.filter( (balance): balance is NonNativeBalance => balance.asset_type !== 'native' ) ?? [] } catch (e: unknown) { if (e instanceof NotFoundError) { balance = '0' } else { throw e } } const formattedBalance = parseCurrencyUnit(config.unit, balance) const spendableBalance = account ? await getAccountSpendableBalance(config, formattedBalance, account) : new BigNumber(0) return { blockHeight: account?.sequence ? new BigNumber(account.sequence).toNumber() : 0, balance: formattedBalance, spendableBalance, assets, } } /** * Fetch operations for a single account from indexer * * @param {string} accountId * @param {string} addr * @param {string} order - "desc" or "asc" order of returned records * @param {string} cursor - point to start fetching records * @param {number} maxOperations - maximum number of operations to return, stops fetching after reaching this threshold * * @return {Operation[]} */ export async function fetchAllOperations( config: StellarCoinConfig, accountId: string, addr: string, order: 'asc' | 'desc', cursor: string = '', maxOperations?: number ): Promise { if (!addr) { return [] } const limit = config.explorer.fetchLimit ?? FETCH_LIMIT let operations: Operation[] = [] let fetchedOpsCount = limit try { let rawOperations = await getServer(config) .operations() .forAccount(addr) .limit(limit) .order(order) .cursor(cursor) .includeFailed(true) .join('transactions') .call() if (!rawOperations || !rawOperations.records.length) { return [] } operations = operations.concat( await rawOperationsToOperations(config.unit, rawOperations.records, addr, accountId, 0) ) while (rawOperations.records.length > 0) { if (maxOperations && fetchedOpsCount >= maxOperations) { break } fetchedOpsCount += limit rawOperations = await rawOperations.next() operations = operations.concat( await rawOperationsToOperations(config.unit, rawOperations.records, addr, accountId, 0) ) } return operations } catch (e: unknown) { // FIXME: terrible hacks, because Stellar SDK fails to cast network failures to typed errors in react-native... // (https://github.com/stellar/js-stellar-sdk/issues/638) const errorMsg = e ? String(e) : '' if (e instanceof NotFoundError || errorMsg.match(/status code 404/)) { return [] } if (errorMsg.match(/status code 4[0-9]{2}/)) { throw new LedgerAPI4xx() } if (errorMsg.match(/status code 5[0-9]{2}/)) { throw new LedgerAPI5xx() } if ( e instanceof NetworkError || errorMsg.match(/ECONNRESET|ECONNREFUSED|ENOTFOUND|EPIPE|ETIMEDOUT/) || errorMsg.match(/undefined is not an object/) ) { throw new NetworkDown() } throw e } } // https://developers.stellar.org/docs/data/horizon/api-reference/get-operations-by-account-id export async function fetchOperations( config: StellarCoinConfig, { accountId, addr, minHeight, order, cursor, limit, }: { accountId: string addr: string minHeight: number order: 'asc' | 'desc' cursor: string | undefined limit?: number | undefined } ): Promise> { const noResult: Page = { items: [], next: '' } if (!addr) { return noResult } const requestedLimit = limit ?? config.explorer.fetchLimit ?? FETCH_LIMIT try { const rawOperations = await getServer(config) .operations() .forAccount(addr) .limit(requestedLimit) .order(order) .cursor(cursor ?? '') .includeFailed(true) .join('transactions') .call() if (!rawOperations || !rawOperations.records.length) { return noResult } const rawOps = rawOperations.records const filteredOps = await rawOperationsToOperations( config.unit, rawOps, addr, accountId, minHeight ) // Always return the cursor so the caller can continue pagination, // even when filteredOps is empty (e.g. page of unsupported types). const nextCursor = rawOps.length < requestedLimit ? '' : rawOps[rawOps.length - 1].paging_token return { items: filteredOps, next: nextCursor } } catch (e: unknown) { // FIXME: terrible hacks, because Stellar SDK fails to cast network failures to typed errors in react-native... // (https://github.com/stellar/js-stellar-sdk/issues/638) // update 2025-04-01: in case of NetworkError, the error.response fields are undefined. Hence we cannot rely on status code // the only way to check is the errror message, which may break at some point const errorMsg = e ? String(e) : '' if (e instanceof NotFoundError || errorMsg.match(/status code 404/)) { return noResult } if (errorMsg.match(/too many requests/i)) { throw new LedgerAPI4xx('status code 4xx', { status: 429, method: 'GET' }) } if (errorMsg.match(/status code 4[0-9]{2}/)) { throw new LedgerAPI4xx() } if (errorMsg.match(/status code 5[0-9]{2}/)) { throw new LedgerAPI5xx() } if ( e instanceof NetworkError || errorMsg.match(/ECONNRESET|ECONNREFUSED|ENOTFOUND|EPIPE|ETIMEDOUT/) || errorMsg.match(/undefined is not an object/) ) { throw new NetworkDown() } throw e } } export async function fetchAccountNetworkInfo( config: StellarCoinConfig, account: string ): Promise { try { const extendedAccount = await getServer(config).accounts().accountId(account).call() const baseReserve = getReservedBalance(extendedAccount) const { recommendedFee, networkCongestionLevel, baseFee } = await fetchBaseFee(config) return { family: 'stellar', fees: new BigNumber(recommendedFee.toString()), baseFee: new BigNumber(baseFee.toString()), baseReserve, networkCongestionLevel, } } catch { return { family: 'stellar', fees: new BigNumber(0), baseFee: new BigNumber(100), baseReserve: new BigNumber(0), } } } export async function fetchSequence( config: StellarCoinConfig, address: string ): Promise { const extendedAccount = await loadAccount(config, address) return extendedAccount ? new BigNumber(extendedAccount.sequence) : new BigNumber(0) } export async function fetchSigners(config: StellarCoinConfig, account: string): Promise { try { const extendedAccount = await getServer(config).accounts().accountId(account).call() return extendedAccount.signers } catch { return [] } } function interpretedError(error: unknown): unknown { const body = getHorizonErrorBody(error) if (body) { const cause = error instanceof Error ? error : new Error(String(error)) return makeBroadcastFailedError(body, cause) } return error } export async function broadcastTransaction( context: StellarContext, signedTransaction: string ): Promise { const config = await context.config() try { patchHermesTypedArraysIfNeeded(context.logger) const transaction = new StellarSdkTransaction(signedTransaction, Networks.PUBLIC) try { const res = await getServer(config).submitTransaction(transaction, { skipMemoRequiredCheck: true, }) return res.hash } catch (error: unknown) { throw interpretedError(error) } } finally { // Restore unpatchHermesTypedArrays(context.logger) } } export async function loadAccount( config: StellarCoinConfig, addr: string ): Promise { if (!addr || !addr.length) { return null } try { return await getServer(config).loadAccount(addr) } catch { return null } } export async function getLastBlock(config: StellarCoinConfig): Promise<{ height: number hash: string time: Date }> { let ledger: Horizon.ServerApi.CollectionPage try { ledger = await getServer(config).ledgers().order('desc').limit(1).call() } catch (error) { const message = error instanceof Error ? error.message : String(error) throw new UnknownError(message, error) } if (ledger.records.length === 0) { throw new BlockNotFoundError() } return { height: ledger.records[0].sequence, hash: ledger.records[0].hash, time: new Date(ledger.records[0].closed_at), } } // SDK bug #948: LedgerCallBuilder.ledger(seq).call() types as CollectionPage // but Horizon GET /ledgers/{seq} returns a single LedgerRecord at runtime. function isLedgerRecord(value: unknown): value is Horizon.ServerApi.LedgerRecord { return ( value !== null && typeof value === 'object' && 'hash' in value && typeof value.hash === 'string' && 'sequence' in value && typeof value.sequence === 'number' && 'closed_at' in value && typeof value.closed_at === 'string' ) } /** * Loads a single ledger (closed block) by sequence number. * * Horizon `GET /ledgers/{sequence}` returns one ledger resource, not a page with `records`; * do not read `records[0]` from this `.call()` result. * * @throws Error when the ledger does not exist (Horizon 404). */ export async function fetchLedgerRecord( config: StellarCoinConfig, sequence: number ): Promise { try { // Horizon returns a single ledger; SDK typings still use CollectionPage for this builder. const result = await getServer(config).ledgers().ledger(sequence).call() if (!isLedgerRecord(result)) { throw new Error(`Unexpected Horizon response shape for ledger ${sequence}`) } return result } catch (e: unknown) { throwHorizonLedgerOrOperationsError(e, `Stellar ledger ${sequence} not found`) } } /** * Returns all operations included in `ledgerSequence`, ascending, including failed txs, * with joined transaction payloads (same pattern as account operation listing). * * @throws Error when the ledger does not exist (Horizon 404). */ export async function fetchAllLedgerOperations( config: StellarCoinConfig, ledgerSequence: number ): Promise { const limit = config.explorer.fetchLimit ?? FETCH_LIMIT try { let response = await getServer(config) .operations() .forLedger(ledgerSequence) .includeFailed(true) .join('transactions') .order('asc') .limit(limit) .call() const records: RawOperation[] = [...response.records] while (response.records.length === limit) { response = await response.next() records.push(...response.records) if (response.records.length === 0) { break } } return records } catch (e: unknown) { throwHorizonLedgerOrOperationsError(e, `Stellar ledger ${ledgerSequence} not found`) } } export const getRecipientAccount: CacheRes< [StellarCoinConfig, { recipient: string }], { id: string | null isMuxedAccount: boolean assetIds: string[] } | null > = makeLRUCache( async (config, { recipient }) => await recipientAccount(config, recipient), // Key on the explorer URL too: different Horizon instances (testnet/mainnet, proxies) must not // share cache entries for the same recipient. (config, extract) => `${config.explorer.url}:${extract.recipient}`, { max: 300, ttl: 5 * 60, } // 5 minutes ) async function recipientAccount( config: StellarCoinConfig, address: string | undefined ): Promise<{ id: string | null isMuxedAccount: boolean assetIds: string[] } | null> { if (!address) { return null } let accountAddress = address const isMuxedAccount = StrKey.isValidMed25519PublicKey(address) if (isMuxedAccount) { const muxedAccount = MuxedAccount.fromAddress(address, '0') accountAddress = muxedAccount.baseAccount().accountId() } const account: Horizon.AccountResponse | null = await loadAccount(config, accountAddress) if (!account) { return null } return { id: account.id, isMuxedAccount, assetIds: account.balances.map(getBalanceId).filter((id): id is string => id !== null), } } function getBalanceId(balance: Horizon.HorizonApi.BalanceLine): string | null { switch (balance.asset_type) { case 'native': return 'native' case 'liquidity_pool_shares': return balance.liquidity_pool_id || null case 'credit_alphanum4': case 'credit_alphanum12': return `${balance.asset_code}:${balance.asset_issuer}` default: return null } }