@ledgerhq/coin-stellar
Version:
Ledger Stellar Coin integration
662 lines (586 loc) • 20.4 kB
text/typescript
// SPDX-FileCopyrightText: © 2026 LEDGER SAS
// SPDX-License-Identifier: Apache-2.0
import type { Operation, Page } from '@ledgerhq/coin-module-framework/api/types'
import type { CacheRes } from '@ledgerhq/live-network/cache'
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 {
type BalanceAsset,
type NetworkInfo,
type RawOperation,
type Signer,
NetworkCongestionLevel,
} from '../types'
import { StellarBroadcastFailedError, type StellarDecodedResultXdr } from '../types/errors'
import {
documentationSummaryFromHorizonBody,
getHorizonErrorBody,
type HorizonErrorBody,
} 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<string, Horizon.Server>()
function getServer(): Horizon.Server {
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<object, number>()
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(): void {
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: any) => {
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: string): string {
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: Horizon.ServerApi.AccountRecord): BigNumber => {
return parseAPIValue(getReservedBalance(account).toString())
}
export async function getAccountSpendableBalance(
balance: BigNumber,
account: Horizon.ServerApi.AccountRecord
): Promise<BigNumber> {
const minimumBalance = getMinimumBalance(account)
const { recommendedFee } = await fetchBaseFee()
return BigNumber.max(balance.minus(minimumBalance).minus(recommendedFee), 0)
}
export async function fetchBaseFee(): Promise<{
baseFee: number
recommendedFee: number
networkCongestionLevel: NetworkCongestionLevel
}> {
// 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: string): Promise<{
blockHeight: number
balance: BigNumber
spendableBalance: BigNumber
assets: BalanceAsset[]
}> {
let account: Horizon.ServerApi.AccountRecord = {} as Horizon.ServerApi.AccountRecord
let assets: BalanceAsset[] = []
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'
}) as BalanceAsset[]
} 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: string,
addr: string,
order: 'asc' | 'desc',
cursor: string = '',
maxOperations?: number
): Promise<Operation[]> {
if (!addr) {
return []
}
const limit = coinConfig.getCoinConfig().explorer.fetchLimit ?? FETCH_LIMIT
let operations: Operation[] = []
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 as RawOperation[], 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 as RawOperation[], 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({
accountId,
addr,
minHeight,
order,
cursor,
limit,
}: {
accountId: string
addr: string
minHeight: number
order: 'asc' | 'desc'
cursor: string | undefined
limit?: number | undefined
}): Promise<Page<Operation>> {
const noResult: Page<Operation> = { 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 as RawOperation[]
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: 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, 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: string): Promise<NetworkInfo> {
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: string): Promise<BigNumber> {
const extendedAccount = await loadAccount(address)
return extendedAccount ? new BigNumber(extendedAccount.sequence) : new BigNumber(0)
}
export async function fetchSigners(account: string): Promise<Signer[]> {
try {
const extendedAccount = await getServer().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(signedTransaction: string): Promise<string> {
try {
patchHermesTypedArraysIfNeeded()
const transaction = new StellarSdkTransaction(signedTransaction, Networks.PUBLIC)
try {
const res = await getServer().submitTransaction(transaction, {
skipMemoRequiredCheck: true,
})
return res.hash
} catch (error: unknown) {
throw interpretedError(error)
}
} finally {
// Restore
unpatchHermesTypedArrays()
}
}
export async function loadAccount(addr: string): Promise<Horizon.AccountResponse | null> {
if (!addr || !addr.length) {
return null
}
try {
return await getServer().loadAccount(addr)
} catch {
return null
}
}
export async function getLastBlock(): Promise<{
height: number
hash: string
time: Date
}> {
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: number): Promise<Horizon.ServerApi.LedgerRecord> {
try {
const record = await getServer().ledgers().ledger(sequence).call()
// Horizon returns a single ledger; SDK typings still use CollectionPage for this builder.
return record as unknown as Horizon.ServerApi.LedgerRecord
} 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(ledgerSequence: number): Promise<RawOperation[]> {
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: RawOperation[] = [...(response.records as RawOperation[])]
while (response.records.length === limit) {
response = await response.next()
records.push(...(response.records as RawOperation[]))
if (response.records.length === 0) {
break
}
}
return records
} catch (e: unknown) {
throwHorizonLedgerOrOperationsError(e, `Stellar ledger ${ledgerSequence} not found`)
}
}
export const getRecipientAccount: CacheRes<
Array<{
recipient: string
}>,
{
id: string | null
isMuxedAccount: boolean
assetIds: string[]
} | null
> = makeLRUCache(
async ({ recipient }) => await recipientAccount(recipient),
(extract) => extract.recipient,
{
max: 300,
ttl: 5 * 60,
} // 5 minutes
)
async function recipientAccount(address?: string): 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(accountAddress)
if (!account) {
return null
}
return {
id: account.id,
isMuxedAccount,
assetIds: account.balances.reduce((allAssets: any[], balance: any) => {
return [...allAssets, getBalanceId(balance)]
}, []),
}
}
function getBalanceId(balance: BalanceAsset): 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
}
}