UNPKG

@ledgerhq/coin-stellar

Version:
526 lines 21.2 kB
"use strict"; // SPDX-FileCopyrightText: © 2026 LEDGER SAS // SPDX-License-Identifier: Apache-2.0 var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.getRecipientAccount = void 0; exports.registerHorizonInterceptors = registerHorizonInterceptors; exports.useConfigHostAndProtocol = useConfigHostAndProtocol; exports.getAccountSpendableBalance = getAccountSpendableBalance; exports.fetchBaseFee = fetchBaseFee; exports.fetchAccount = fetchAccount; exports.fetchAllOperations = fetchAllOperations; exports.fetchOperations = fetchOperations; exports.fetchAccountNetworkInfo = fetchAccountNetworkInfo; exports.fetchSequence = fetchSequence; exports.fetchSigners = fetchSigners; exports.broadcastTransaction = broadcastTransaction; exports.loadAccount = loadAccount; exports.getLastBlock = getLastBlock; exports.fetchLedgerRecord = fetchLedgerRecord; exports.fetchAllLedgerOperations = fetchAllLedgerOperations; const errors_1 = require("@ledgerhq/errors"); const cache_1 = require("@ledgerhq/live-network/cache"); const logs_1 = require("@ledgerhq/logs"); const stellar_sdk_1 = require("@stellar/stellar-sdk"); const bignumber_js_1 = require("bignumber.js"); const config_1 = __importDefault(require("../config")); const common_1 = require("../logic/common"); const polyfill_1 = require("../polyfill"); const types_1 = require("../types"); const errors_2 = require("../types/errors"); const horizonErrorBody_1 = require("./horizonErrorBody"); const horizonLedgerErrors_1 = require("./horizonLedgerErrors"); const serialization_1 = require("./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) { if (!resultXdrBase64) { return undefined; } try { const tr = stellar_sdk_1.xdr.TransactionResult.fromXDR(resultXdrBase64, 'base64'); const feeChargedStroops = tr.feeCharged().toString(); const resultXdrSwitchName = tr.result().switch().name; const decodedResultXdr = { feeChargedStroops, resultSwitch: resultXdrSwitchName, }; return { feeChargedStroops, resultXdrSwitchName, decodedResultXdr }; } catch { return { decodedResultXdr: { decodeFailed: true, rawResultXdrBase64: resultXdrBase64 }, }; } } function makeBroadcastFailedError(body, cause) { const extras = body.extras; const horizonTransactionCode = extras?.result_codes?.transaction ?? ''; const horizonOperationCodes = extras?.result_codes?.operations; const documentationSummary = (0, horizonErrorBody_1.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 errors_2.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() { const url = config_1.default.getCoinConfig().explorer.url; let server = servers.get(url); if (server === undefined) { server = new stellar_sdk_1.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 coinConfig is initialised (i.e. inside createApi). * * Logging is gated on coinConfig.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. */ function registerHorizonInterceptors() { if (interceptorsRegistered) return; interceptorsRegistered = true; stellar_sdk_1.Horizon.AxiosClient.interceptors.request.use((config) => { let enableNetworkLogs = false; try { enableNetworkLogs = !!config_1.default.getCoinConfig().enableNetworkLogs; } catch { return config; } if (enableNetworkLogs) { const { baseURL, url, method = '', data } = config; (0, logs_1.log)('network', `${method} ${baseURL ?? ''}${url}`, { data }); requestStartTimes.set(config, Date.now()); } return config; }); stellar_sdk_1.Horizon.AxiosClient.interceptors.response.use((response) => { let enableNetworkLogs = false; try { enableNetworkLogs = !!config_1.default.getCoinConfig().enableNetworkLogs; } catch { // coinConfig not initialised on this instance; skip logging and leave URLs untouched } if (enableNetworkLogs) { // response.config is typed as `any` in stellar-sdk's HttpClientResponse const startTime = requestStartTimes.get(response.config) ?? 0; requestStartTimes.delete(response.config); (0, logs_1.log)('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(next_href); } response?.data?._embedded?.records?.forEach((r) => { const href = r.transaction?._links?.ledger?.href; if (href) r.transaction._links.ledger.href = useConfigHostAndProtocol(href); }); return response; }); } // It replaces the host and the protocol of the URL returned with the original ones. function useConfigHostAndProtocol(url) { const originalUrl = new URL(config_1.default.getCoinConfig().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 = (account) => { return (0, common_1.parseAPIValue)((0, serialization_1.getReservedBalance)(account).toString()); }; async function getAccountSpendableBalance(balance, account) { const minimumBalance = getMinimumBalance(account); const { recommendedFee } = await fetchBaseFee(); return bignumber_js_1.BigNumber.max(balance.minus(minimumBalance).minus(recommendedFee), 0); } async function fetchBaseFee() { // For tests if (config_1.default.getCoinConfig().useStaticFees) { return { baseFee: 100, recommendedFee: 100, networkCongestionLevel: types_1.NetworkCongestionLevel.LOW, }; } const baseFee = new bignumber_js_1.BigNumber(stellar_sdk_1.BASE_FEE).toNumber() || FALLBACK_BASE_FEE; let recommendedFee = baseFee; let networkCongestionLevel = types_1.NetworkCongestionLevel.MEDIUM; try { const feeStats = await getServer().feeStats(); const ledgerCapacityUsage = feeStats.ledger_capacity_usage; recommendedFee = new bignumber_js_1.BigNumber(feeStats.fee_charged.mode).toNumber(); if (new bignumber_js_1.BigNumber(ledgerCapacityUsage).toNumber() > TRESHOLD_LOW && new bignumber_js_1.BigNumber(ledgerCapacityUsage).toNumber() <= TRESHOLD_MEDIUM) { networkCongestionLevel = types_1.NetworkCongestionLevel.MEDIUM; } else if (new bignumber_js_1.BigNumber(ledgerCapacityUsage).toNumber() > TRESHOLD_MEDIUM) { networkCongestionLevel = types_1.NetworkCongestionLevel.HIGH; } else { networkCongestionLevel = types_1.NetworkCongestionLevel.LOW; } } catch { // do nothing, will use defaults } return { baseFee, recommendedFee, networkCongestionLevel, }; } /** * Get all account-related data * * @async * @param {string} addr */ async function fetchAccount(addr) { let account = {}; let assets = []; let balance = '0'; try { account = await getServer().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) => { return balance.asset_type !== 'native'; }); } catch { balance = '0'; } const formattedBalance = (0, common_1.parseAPIValue)(balance); const spendableBalance = await getAccountSpendableBalance(formattedBalance, account); return { blockHeight: account.sequence ? new bignumber_js_1.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[]} */ async function fetchAllOperations(accountId, addr, order, cursor = '', maxOperations) { if (!addr) { return []; } const limit = config_1.default.getCoinConfig().explorer.fetchLimit ?? FETCH_LIMIT; let operations = []; let fetchedOpsCount = limit; try { let rawOperations = await getServer() .operations() .forAccount(addr) .limit(limit) .order(order) .cursor(cursor) .includeFailed(true) .join('transactions') .call(); if (!rawOperations || !rawOperations.records.length) { return []; } operations = operations.concat(await (0, serialization_1.rawOperationsToOperations)(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 (0, serialization_1.rawOperationsToOperations)(rawOperations.records, addr, accountId, 0)); } return operations; } catch (e) { // 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 stellar_sdk_1.NotFoundError || errorMsg.match(/status code 404/)) { return []; } if (errorMsg.match(/status code 4[0-9]{2}/)) { throw new errors_1.LedgerAPI4xx(); } if (errorMsg.match(/status code 5[0-9]{2}/)) { throw new errors_1.LedgerAPI5xx(); } if (e instanceof stellar_sdk_1.NetworkError || errorMsg.match(/ECONNRESET|ECONNREFUSED|ENOTFOUND|EPIPE|ETIMEDOUT/) || errorMsg.match(/undefined is not an object/)) { throw new errors_1.NetworkDown(); } throw e; } } // https://developers.stellar.org/docs/data/horizon/api-reference/get-operations-by-account-id async function fetchOperations({ accountId, addr, minHeight, order, cursor, limit, }) { const noResult = { items: [], next: '' }; if (!addr) { return noResult; } const requestedLimit = limit ?? config_1.default.getCoinConfig().explorer.fetchLimit ?? FETCH_LIMIT; try { const rawOperations = await getServer() .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 (0, serialization_1.rawOperationsToOperations)(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) { // 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 stellar_sdk_1.NotFoundError || errorMsg.match(/status code 404/)) { return noResult; } if (errorMsg.match(/too many requests/i)) { throw new errors_1.LedgerAPI4xx('status code 4xx', { status: 429, url: undefined, method: 'GET' }); } if (errorMsg.match(/status code 4[0-9]{2}/)) { throw new errors_1.LedgerAPI4xx(); } if (errorMsg.match(/status code 5[0-9]{2}/)) { throw new errors_1.LedgerAPI5xx(); } if (e instanceof stellar_sdk_1.NetworkError || errorMsg.match(/ECONNRESET|ECONNREFUSED|ENOTFOUND|EPIPE|ETIMEDOUT/) || errorMsg.match(/undefined is not an object/)) { throw new errors_1.NetworkDown(); } throw e; } } async function fetchAccountNetworkInfo(account) { try { const extendedAccount = await getServer().accounts().accountId(account).call(); const baseReserve = (0, serialization_1.getReservedBalance)(extendedAccount); const { recommendedFee, networkCongestionLevel, baseFee } = await fetchBaseFee(); return { family: 'stellar', fees: new bignumber_js_1.BigNumber(recommendedFee.toString()), baseFee: new bignumber_js_1.BigNumber(baseFee.toString()), baseReserve, networkCongestionLevel, }; } catch { return { family: 'stellar', fees: new bignumber_js_1.BigNumber(0), baseFee: new bignumber_js_1.BigNumber(100), baseReserve: new bignumber_js_1.BigNumber(0), }; } } async function fetchSequence(address) { const extendedAccount = await loadAccount(address); return extendedAccount ? new bignumber_js_1.BigNumber(extendedAccount.sequence) : new bignumber_js_1.BigNumber(0); } async function fetchSigners(account) { try { const extendedAccount = await getServer().accounts().accountId(account).call(); return extendedAccount.signers; } catch { return []; } } function interpretedError(error) { const body = (0, horizonErrorBody_1.getHorizonErrorBody)(error); if (body) { const cause = error instanceof Error ? error : new Error(String(error)); return makeBroadcastFailedError(body, cause); } return error; } async function broadcastTransaction(signedTransaction) { try { (0, polyfill_1.patchHermesTypedArraysIfNeeded)(); const transaction = new stellar_sdk_1.Transaction(signedTransaction, stellar_sdk_1.Networks.PUBLIC); try { const res = await getServer().submitTransaction(transaction, { skipMemoRequiredCheck: true, }); return res.hash; } catch (error) { throw interpretedError(error); } } finally { // Restore (0, polyfill_1.unpatchHermesTypedArrays)(); } } async function loadAccount(addr) { if (!addr || !addr.length) { return null; } try { return await getServer().loadAccount(addr); } catch { return null; } } async function getLastBlock() { const ledger = await getServer().ledgers().order('desc').limit(1).call(); return { height: ledger.records[0].sequence, hash: ledger.records[0].hash, time: new Date(ledger.records[0].closed_at), }; } /** * 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). */ async function fetchLedgerRecord(sequence) { try { const record = await getServer().ledgers().ledger(sequence).call(); // Horizon returns a single ledger; SDK typings still use CollectionPage for this builder. return record; } catch (e) { (0, horizonLedgerErrors_1.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). */ async function fetchAllLedgerOperations(ledgerSequence) { const limit = config_1.default.getCoinConfig().explorer.fetchLimit ?? FETCH_LIMIT; try { let response = await getServer() .operations() .forLedger(ledgerSequence) .includeFailed(true) .join('transactions') .order('asc') .limit(limit) .call(); const records = [...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) { (0, horizonLedgerErrors_1.throwHorizonLedgerOrOperationsError)(e, `Stellar ledger ${ledgerSequence} not found`); } } exports.getRecipientAccount = (0, cache_1.makeLRUCache)(async ({ recipient }) => await recipientAccount(recipient), (extract) => extract.recipient, { max: 300, ttl: 5 * 60, } // 5 minutes ); async function recipientAccount(address) { if (!address) { return null; } let accountAddress = address; const isMuxedAccount = stellar_sdk_1.StrKey.isValidMed25519PublicKey(address); if (isMuxedAccount) { const muxedAccount = stellar_sdk_1.MuxedAccount.fromAddress(address, '0'); accountAddress = muxedAccount.baseAccount().accountId(); } const account = await loadAccount(accountAddress); if (!account) { return null; } return { id: account.id, isMuxedAccount, assetIds: account.balances.reduce((allAssets, balance) => { return [...allAssets, getBalanceId(balance)]; }, []), }; } function getBalanceId(balance) { 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; } } //# sourceMappingURL=horizon.js.map