UNPKG

@ledgerhq/coin-hedera

Version:
480 lines (420 loc) 14 kB
import { LedgerAPI4xx } from "@ledgerhq/errors"; import network from "@ledgerhq/live-network"; import type { LiveNetworkResponse } from "@ledgerhq/live-network/network"; import BigNumber from "bignumber.js"; import { encodeFunctionData, erc20Abi } from "viem"; import { type HederaCoinConfig } from "../config"; import { HEDERA_TRANSACTION_NAMES } from "../constants"; import { HederaAddAccountError } from "../errors"; import { resolveConfig } from "../logic/utils"; import type { HederaMirrorAccountTokensResponse, HederaMirrorBlocksResponse, HederaMirrorTransactionsResponse, HederaMirrorAccount, HederaMirrorAccountsResponse, HederaMirrorBlock, HederaMirrorToken, HederaMirrorTransaction, HederaMirrorNetworkFees, HederaMirrorContractCallResult, HederaMirrorContractCallEstimate, HederaMirrorNode, HederaMirrorNodesResponse, } from "../types"; // keeps old behavior when all pages are fetched const getPaginationDirection = (fetchAllPages: boolean, order: string) => { if (fetchAllPages) return "gt"; return order === "asc" ? "gt" : "lt"; }; async function getAccountsForPublicKey({ configOrCurrencyId, publicKey, }: { configOrCurrencyId: HederaCoinConfig | string; publicKey: string; }): Promise<HederaMirrorAccount[]> { const config = resolveConfig(configOrCurrencyId); let res; try { res = await network<HederaMirrorAccountsResponse>({ method: "GET", url: `${config.apiUrls.mirrorNode}/api/v1/accounts?account.publicKey=${publicKey}&balance=true&limit=100`, }); } catch (e: unknown) { if (e instanceof LedgerAPI4xx) return []; throw e; } const accounts = res.data.accounts; return accounts; } /** * Fetches account information from the Hedera Mirror Node API, excluding transactions. * * @param configOrCurrencyId - Coin config object or currency ID string used to resolve the mirror node URL. * @param address - The Hedera account ID (e.g., "0.0.12345") * @param timestamp - Optional timestamp filter to get historical account state. * Supports comparison operators: * - "lt:1234567890.123456789" - state before the timestamp * - "eq:1234567890.123456789" - state at the timestamp * Used primarily for analyzing state changes in staking operations. * @returns Promise resolving to account data * @throws HederaAddAccountError if account not found (404) */ async function getAccount({ configOrCurrencyId, address, timestamp, }: { configOrCurrencyId: HederaCoinConfig | string; address: string; timestamp?: string; }): Promise<HederaMirrorAccount> { const config = resolveConfig(configOrCurrencyId); try { const params = new URLSearchParams({ transactions: "false", ...(timestamp && { timestamp }), }); const res = await network<HederaMirrorAccount>({ method: "GET", url: `${config.apiUrls.mirrorNode}/api/v1/accounts/${address}?${params.toString()}`, }); const account = res.data; return account; } catch (error) { if (error instanceof LedgerAPI4xx && "status" in error && error.status === 404) { throw new HederaAddAccountError(); } throw error; } } async function getAccountTransactions({ configOrCurrencyId, address, pagingToken, limit = 100, order = "desc", fetchAllPages, }: { configOrCurrencyId: HederaCoinConfig | string; address: string; pagingToken: string | null; limit?: number | undefined; order?: "asc" | "desc" | undefined; fetchAllPages: boolean; }): Promise<{ transactions: HederaMirrorTransaction[]; nextCursor: string | null }> { const config = resolveConfig(configOrCurrencyId); const transactions: HederaMirrorTransaction[] = []; const params = new URLSearchParams({ "account.id": address, limit: limit.toString(), order, }); if (pagingToken) { params.append("timestamp", `${getPaginationDirection(fetchAllPages, order)}:${pagingToken}`); } let nextCursor: string | null = null; let nextPath: string | null = `/api/v1/transactions?${params.toString()}`; // WARNING: don't break the loop when `transactions` array is empty but `links.next` is present // the mirror node API enforces a 60-day max time range per query, even if `timestamp` param is set // see: https://hedera.com/blog/changes-to-the-hedera-operated-mirror-node while (nextPath) { const res: LiveNetworkResponse<HederaMirrorTransactionsResponse> = await network({ method: "GET", url: `${config.apiUrls.mirrorNode}${nextPath}`, }); const newTransactions = res.data.transactions; transactions.push(...newTransactions); nextPath = res.data.links.next; // stop fetching if pagination mode is used and we reached the limit if (!fetchAllPages && transactions.length >= limit) { break; } } // ensure we don't exceed the limit in pagination mode if (!fetchAllPages && transactions.length > limit) { transactions.splice(limit); } // set the next cursor only if we have more transactions to fetch if (!fetchAllPages && nextPath) { const lastTx = transactions.at(-1); nextCursor = lastTx?.consensus_timestamp ?? null; } return { transactions, nextCursor }; } async function getAccountTokens({ configOrCurrencyId, address, }: { configOrCurrencyId: HederaCoinConfig | string; address: string; }): Promise<HederaMirrorToken[]> { const config = resolveConfig(configOrCurrencyId); const tokens: HederaMirrorToken[] = []; const params = new URLSearchParams({ limit: "100", }); let nextPath: string | null = `/api/v1/accounts/${address}/tokens?${params.toString()}`; while (nextPath) { const res: LiveNetworkResponse<HederaMirrorAccountTokensResponse> = await network({ method: "GET", url: `${config.apiUrls.mirrorNode}${nextPath}`, }); const newTokens = res.data.tokens; tokens.push(...newTokens); nextPath = res.data.links.next; } return tokens; } async function getLatestTransaction({ configOrCurrencyId, before, }: { configOrCurrencyId: HederaCoinConfig | string; before: Date; }): Promise<HederaMirrorTransaction> { const config = resolveConfig(configOrCurrencyId); const params = new URLSearchParams({ limit: "1", order: "desc", timestamp: `lt:${before.getTime() / 1000}`, }); const res = await network<HederaMirrorTransactionsResponse>({ method: "GET", url: `${config.apiUrls.mirrorNode}/api/v1/transactions?${params.toString()}`, }); const transaction = res.data.transactions[0]; if (!transaction) { throw new Error("No transactions found on the Hedera network"); } return transaction; } async function getLatestBlock({ configOrCurrencyId, }: { configOrCurrencyId: HederaCoinConfig | string; }): Promise<HederaMirrorBlock> { const config = resolveConfig(configOrCurrencyId); const params = new URLSearchParams({ limit: "1", order: "desc", }); const res = await network<HederaMirrorBlocksResponse>({ method: "GET", url: `${config.apiUrls.mirrorNode}/api/v1/blocks?${params.toString()}`, }); const block = res.data.blocks[0]; if (!block) { throw new Error("No blocks found on the Hedera network"); } return block; } async function getNetworkFees({ configOrCurrencyId, }: { configOrCurrencyId: HederaCoinConfig | string; }): Promise<HederaMirrorNetworkFees> { const config = resolveConfig(configOrCurrencyId); const res = await network<HederaMirrorNetworkFees>({ method: "GET", url: `${config.apiUrls.mirrorNode}/api/v1/network/fees`, }); return res.data; } async function getContractCallResult({ configOrCurrencyId, transactionHash, }: { configOrCurrencyId: HederaCoinConfig | string; transactionHash: string; }): Promise<HederaMirrorContractCallResult> { const config = resolveConfig(configOrCurrencyId); const res = await network<HederaMirrorContractCallResult>({ method: "GET", url: `${config.apiUrls.mirrorNode}/api/v1/contracts/results/${transactionHash}`, }); return res.data; } async function findTransactionByContractCallV2({ configOrCurrencyId, timestamp, payerAddress, }: { configOrCurrencyId: HederaCoinConfig | string; timestamp: string; payerAddress: string; }): Promise<HederaMirrorTransaction | null> { const config = resolveConfig(configOrCurrencyId); // Hgraph API returns timestamp as number and nanoseconds precision is lost during parsing // instead of using `timestamp=eq:${timestamp}`, we need to fetch transactions in a small range // +-10 microseconds is used to bypass hgraph precision issue const timestampAsNumber = new BigNumber(timestamp).multipliedBy(10 ** 9); const timestampDiffNs = new BigNumber(10_000); const from = new BigNumber(timestampAsNumber).minus(timestampDiffNs).dividedBy(10 ** 9); const to = new BigNumber(timestampAsNumber).plus(timestampDiffNs).dividedBy(10 ** 9); const params = new URLSearchParams({ limit: "100", order: "desc" }); params.append("timestamp", `gte:${from.toFixed(9)}`); params.append("timestamp", `lte:${to.toFixed(9)}`); const res = await network<HederaMirrorTransactionsResponse>({ method: "GET", url: `${config.apiUrls.mirrorNode}/api/v1/transactions?${params.toString()}`, }); // try to find main CONTRACT_CALL transaction related to the given address const relatedTx = res.data.transactions.find(tx => { return ( tx.name === HEDERA_TRANSACTION_NAMES.ContractCall && tx.transaction_id.startsWith(payerAddress) && tx.parent_consensus_timestamp === null ); }); return relatedTx ?? null; } async function estimateContractCallGas({ configOrCurrencyId, senderEvmAddress, recipientEvmAddress, contractEvmAddress, amount, }: { configOrCurrencyId: HederaCoinConfig | string; senderEvmAddress: string; recipientEvmAddress: string; contractEvmAddress: string; amount: bigint; }): Promise<BigNumber> { const config = resolveConfig(configOrCurrencyId); const res = await network<HederaMirrorContractCallEstimate>({ method: "POST", url: `${config.apiUrls.mirrorNode}/api/v1/contracts/call`, data: { block: "latest", estimate: true, from: senderEvmAddress, to: contractEvmAddress, data: encodeFunctionData({ abi: erc20Abi, functionName: "transfer", args: [recipientEvmAddress as `0x${string}`, amount], }), }, }); return new BigNumber(res.data.result); } async function getTransactionsByTimestampRange({ configOrCurrencyId, address, startTimestamp, endTimestamp, limit = 100, order = "desc", }: { configOrCurrencyId: HederaCoinConfig | string; address?: string; startTimestamp: `${string}:${string}`; endTimestamp: `${string}:${string}`; limit?: number; order?: "asc" | "desc"; }): Promise<HederaMirrorTransaction[]> { const config = resolveConfig(configOrCurrencyId); const transactions: HederaMirrorTransaction[] = []; const params = new URLSearchParams({ limit: limit.toString(), order, ...(address && { "account.id": address }), }); params.append("timestamp", startTimestamp); params.append("timestamp", endTimestamp); let nextPath: string | null = `/api/v1/transactions?${params.toString()}`; while (nextPath) { const res: LiveNetworkResponse<HederaMirrorTransactionsResponse> = await network({ method: "GET", url: `${config.apiUrls.mirrorNode}${nextPath}`, }); const newTransactions = res.data.transactions; transactions.push(...newTransactions); nextPath = res.data.links.next; } return transactions; } async function getNode({ configOrCurrencyId, nodeId, }: { configOrCurrencyId: HederaCoinConfig | string; nodeId: number; }): Promise<HederaMirrorNode | null> { const config = resolveConfig(configOrCurrencyId); const params = new URLSearchParams({ "node.id": `eq:${nodeId}`, limit: "1", }); const res = await network<HederaMirrorNodesResponse>({ method: "GET", url: `${config.apiUrls.mirrorNode}/api/v1/network/nodes?${params.toString()}`, }); return res.data.nodes[0] ?? null; } async function getNodes({ configOrCurrencyId, cursor, limit = 100, order = "desc", fetchAllPages, }: { configOrCurrencyId: HederaCoinConfig | string; cursor?: string; limit?: number; order?: "asc" | "desc"; fetchAllPages: boolean; }): Promise<{ nodes: HederaMirrorNode[]; nextCursor: string | null }> { const config = resolveConfig(configOrCurrencyId); const nodes: HederaMirrorNode[] = []; const params = new URLSearchParams({ order, limit: limit.toString(), }); if (cursor) { params.append("node.id", `${getPaginationDirection(fetchAllPages, order)}:${cursor}`); } let nextCursor: string | null = null; let nextPath: string | null = `/api/v1/network/nodes?${params.toString()}`; while (nextPath) { const res: LiveNetworkResponse<HederaMirrorNodesResponse> = await network({ method: "GET", url: `${config.apiUrls.mirrorNode}${nextPath}`, }); const newNodes = res.data.nodes; nodes.push(...newNodes); nextPath = res.data.links.next; // stop fetching if pagination mode is used and we reached the limit if (!fetchAllPages && nodes.length >= limit) { break; } } // ensure we don't exceed the limit in pagination mode if (!fetchAllPages && nodes.length > limit) { nodes.splice(limit); } // set the next cursor only if we have more nodes to fetch if (!fetchAllPages && nextPath) { const lastNode = nodes.at(-1); nextCursor = lastNode?.node_id?.toString() ?? null; } return { nodes, nextCursor }; } export const apiClient = { getAccountsForPublicKey, getAccount, getAccountTokens, getAccountTransactions, getLatestBlock, getLatestTransaction, getNetworkFees, getContractCallResult, findTransactionByContractCallV2, estimateContractCallGas, getTransactionsByTimestampRange, getNode, getNodes, };