@ledgerhq/coin-stellar
Version:
Ledger Stellar Coin integration
505 lines • 19.8 kB
JavaScript
// SPDX-FileCopyrightText: © 2026 LEDGER SAS
// SPDX-License-Identifier: Apache-2.0
import { LedgerAPI4xx, LedgerAPI5xx, NetworkDown } from '@ledgerhq/errors';
import { makeLRUCache } from '@ledgerhq/live-network/cache';
import { log } from '@ledgerhq/logs';
import { BASE_FEE, Horizon, MuxedAccount, NetworkError, Networks, NotFoundError, Transaction as StellarSdkTransaction, StrKey, xdr, } from '@stellar/stellar-sdk';
import { BigNumber } from 'bignumber.js';
import coinConfig from '../config';
import { parseAPIValue } from '../logic/common';
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) {
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 = {
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 = 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() {
const url = coinConfig.getCoinConfig().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 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.
*/
export function registerHorizonInterceptors() {
if (interceptorsRegistered)
return;
interceptorsRegistered = true;
Horizon.AxiosClient.interceptors.request.use((config) => {
let enableNetworkLogs = false;
try {
enableNetworkLogs = !!coinConfig.getCoinConfig().enableNetworkLogs;
}
catch {
return config;
}
if (enableNetworkLogs) {
const { baseURL, url, method = '', data } = config;
log('network', `${method} ${baseURL ?? ''}${url}`, { data });
requestStartTimes.set(config, Date.now());
}
return config;
});
Horizon.AxiosClient.interceptors.response.use((response) => {
let enableNetworkLogs = false;
try {
enableNetworkLogs = !!coinConfig.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);
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.
export function useConfigHostAndProtocol(url) {
const originalUrl = new URL(coinConfig.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 parseAPIValue(getReservedBalance(account).toString());
};
export async function getAccountSpendableBalance(balance, account) {
const minimumBalance = getMinimumBalance(account);
const { recommendedFee } = await fetchBaseFee();
return BigNumber.max(balance.minus(minimumBalance).minus(recommendedFee), 0);
}
export async function fetchBaseFee() {
// For tests
if (coinConfig.getCoinConfig().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().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,
};
}
/**
* Get all account-related data
*
* @async
* @param {string} addr
*/
export 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 = parseAPIValue(balance);
const spendableBalance = await getAccountSpendableBalance(formattedBalance, account);
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(accountId, addr, order, cursor = '', maxOperations) {
if (!addr) {
return [];
}
const limit = coinConfig.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 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 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 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({ accountId, addr, minHeight, order, cursor, limit, }) {
const noResult = { items: [], next: '' };
if (!addr) {
return noResult;
}
const requestedLimit = limit ?? coinConfig.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 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 NotFoundError || errorMsg.match(/status code 404/)) {
return noResult;
}
if (errorMsg.match(/too many requests/i)) {
throw new LedgerAPI4xx('status code 4xx', { status: 429, url: undefined, 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(account) {
try {
const extendedAccount = await getServer().accounts().accountId(account).call();
const baseReserve = getReservedBalance(extendedAccount);
const { recommendedFee, networkCongestionLevel, baseFee } = await fetchBaseFee();
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(address) {
const extendedAccount = await loadAccount(address);
return extendedAccount ? new BigNumber(extendedAccount.sequence) : new BigNumber(0);
}
export async function fetchSigners(account) {
try {
const extendedAccount = await getServer().accounts().accountId(account).call();
return extendedAccount.signers;
}
catch {
return [];
}
}
function interpretedError(error) {
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(signedTransaction) {
try {
patchHermesTypedArraysIfNeeded();
const transaction = new StellarSdkTransaction(signedTransaction, Networks.PUBLIC);
try {
const res = await getServer().submitTransaction(transaction, {
skipMemoRequiredCheck: true,
});
return res.hash;
}
catch (error) {
throw interpretedError(error);
}
}
finally {
// Restore
unpatchHermesTypedArrays();
}
}
export async function loadAccount(addr) {
if (!addr || !addr.length) {
return null;
}
try {
return await getServer().loadAccount(addr);
}
catch {
return null;
}
}
export 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).
*/
export 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) {
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(ledgerSequence) {
const limit = coinConfig.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) {
throwHorizonLedgerOrOperationsError(e, `Stellar ledger ${ledgerSequence} not found`);
}
}
export const getRecipientAccount = 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 = StrKey.isValidMed25519PublicKey(address);
if (isMuxedAccount) {
const muxedAccount = 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