@ledgerhq/coin-stellar
Version:
Ledger Stellar Coin integration
499 lines (436 loc) • 14.5 kB
text/typescript
import { parseCurrencyUnit } from "@ledgerhq/coin-framework/currencies";
import { getCryptoCurrencyById } from "@ledgerhq/cryptoassets/currencies";
import { LedgerAPI4xx, LedgerAPI5xx, NetworkDown } from "@ledgerhq/errors";
import type { CacheRes } from "@ledgerhq/live-network/cache";
import { makeLRUCache } from "@ledgerhq/live-network/cache";
import { log } from "@ledgerhq/logs";
import type { Account } from "@ledgerhq/types-live";
import {
// @ts-expect-error stellar-sdk ts definition missing?
AccountRecord,
BASE_FEE,
Horizon,
MuxedAccount,
NetworkError,
Networks,
NotFoundError,
Transaction as StellarSdkTransaction,
StrKey,
} from "@stellar/stellar-sdk";
import { BigNumber } from "bignumber.js";
import coinConfig from "../config";
import {
type BalanceAsset,
type NetworkInfo,
type RawOperation,
type Signer,
NetworkCongestionLevel,
StellarOperation,
} from "../types";
import {
getAccountSpendableBalance,
getReservedBalance,
rawOperationsToOperations,
} from "./serialization";
import { patchHermesTypedArraysIfNeeded, unpatchHermesTypedArrays } from "../polyfill";
const FALLBACK_BASE_FEE = 100;
const TRESHOLD_LOW = 0.5;
const TRESHOLD_MEDIUM = 0.75;
const FETCH_LIMIT = 100;
const currency = getCryptoCurrencyById("stellar");
// 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;
}
// Constants
export const BASE_RESERVE = 0.5;
export const BASE_RESERVE_MIN_COUNT = 2;
export const MIN_BALANCE = 1;
// Due to the inconsistency between the axios version (1.6.5) used by `stellar-sdk`
// and the version (0.26.1) used by `@ledgerhq/live-network/network`, it is not possible to use the interceptors
// provided by `@ledgerhq/live-network/network`.
Horizon.AxiosClient.interceptors.request.use(config => {
if (!coinConfig.getCoinConfig().enableNetworkLogs) {
return config;
}
const { url, method, data } = config;
log("network", `${method} ${url}`, { data });
return config;
});
// This function allows to fix the URL, because the url returned by the Stellar SDK is not the correct one.
// It replaces the host of the URL returned with the host of the explorer.
function useConfigHost(url: string): string {
const u = new URL(url);
u.host = new URL(coinConfig.getCoinConfig().explorer.url).host;
return u.toString();
}
Horizon.AxiosClient.interceptors.response.use(response => {
if (coinConfig.getCoinConfig().enableNetworkLogs) {
const { url, method } = response.config;
log("network-success", `${response.status} ${method} ${url}`, { 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 = useConfigHost(next_href);
}
response?.data?._embedded?.records?.forEach((r: any) => {
const href = r.transaction?._links?.ledger?.href;
if (href) r.transaction._links.ledger.href = useConfigHost(href);
});
return response;
});
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 (e) {
// 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 (e) {
balance = "0";
}
const formattedBalance = parseCurrencyUnit(currency.units[0], 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<StellarOperation[]> {
if (!addr) {
return [];
}
const limit = coinConfig.getCoinConfig().explorer.fetchLimit ?? FETCH_LIMIT;
let operations: StellarOperation[] = [];
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<[StellarOperation[], string]> {
const noResult: [StellarOperation[], string] = [[], ""];
if (!addr) {
return noResult;
}
const defaultFetchLimit = coinConfig.getCoinConfig().explorer.fetchLimit ?? FETCH_LIMIT;
try {
const rawOperations = await getServer()
.operations()
.forAccount(addr)
.limit(limit ?? defaultFetchLimit)
.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);
// in this context, if we have filtered out operations it means those operations were < minHeight, so we are done
const nextCursor =
filteredOps.length == rawOps.length ? rawOps[rawOps.length - 1].paging_token : "";
return [filteredOps, 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: Account): Promise<NetworkInfo> {
try {
const extendedAccount = await getServer().accounts().accountId(account.freshAddress).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 (error) {
return {
family: "stellar",
fees: new BigNumber(0),
baseFee: new BigNumber(0),
baseReserve: new BigNumber(0),
};
}
}
export async function fetchSequence(account: Account): Promise<BigNumber> {
const extendedAccount = await loadAccount(account.freshAddress);
return extendedAccount ? new BigNumber(extendedAccount.sequence) : new BigNumber(0);
}
export async function fetchSigners(account: Account): Promise<Signer[]> {
try {
const extendedAccount = await getServer().accounts().accountId(account.freshAddress).call();
return extendedAccount.signers;
} catch (error) {
return [];
}
}
export async function broadcastTransaction(signedTransaction: string): Promise<string> {
patchHermesTypedArraysIfNeeded();
const transaction = new StellarSdkTransaction(signedTransaction, Networks.PUBLIC);
// Immediately restore
unpatchHermesTypedArrays();
const res = await getServer().submitTransaction(transaction, {
skipMemoRequiredCheck: true,
});
return res.hash;
}
export async function loadAccount(addr: string): Promise<AccountRecord | null> {
if (!addr || !addr.length) {
return null;
}
try {
return await getServer().loadAccount(addr);
} catch (e) {
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),
};
}
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: AccountRecord = 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;
}
}