UNPKG

@ledgerhq/coin-hedera

Version:
840 lines (710 loc) 29.4 kB
import { createHash } from "crypto"; import { AccountId, EntityIdHelper, Timestamp, Transaction as HederaSDKTransaction, TransactionId, } from "@hashgraph/sdk"; import type { AssetInfo, TransactionIntent } from "@ledgerhq/coin-module-framework/api/types"; import { findCryptoCurrencyById } from "@ledgerhq/cryptoassets/currencies"; import { getFiatCurrencyByTicker } from "@ledgerhq/cryptoassets/fiats"; import { InvalidAddress } from "@ledgerhq/errors"; import cvsApi from "@ledgerhq/live-countervalues/api/index"; import { getEnv } from "@ledgerhq/live-env"; import { makeLRUCache, seconds } from "@ledgerhq/live-network/cache"; import { log } from "@ledgerhq/logs"; import type { Currency, ExplorerView, TokenCurrency } from "@ledgerhq/types-cryptoassets"; import type { AccountLike, Operation as LiveOperation, OperationType } from "@ledgerhq/types-live"; import BigNumber from "bignumber.js"; import invariant from "invariant"; import { HEDERA_DELEGATION_STATUS, HEDERA_OPERATION_TYPES, HEDERA_TRANSACTION_MODES, SYNTHETIC_BLOCK_WINDOW_SECONDS, TINYBAR_SCALE, OP_TYPES_EXCLUDING_FEES, HEDERA_TRANSACTION_NAMES, STAKING_REWARD_HASH_SUFFIX, } from "../constants"; import { HederaRecipientInvalidChecksum } from "../errors"; import { apiClient } from "../network/api"; import { rpcClient } from "../network/rpc"; import { getCurrentHederaPreloadData } from "../preload-data"; import type { EnrichedERC20Transfer, HederaAccount, HederaMemo, HederaMirrorTransaction, HederaOperationExtra, HederaTxData, HederaValidator, MergedTransaction, OperationDetailsExtraField, StakingAnalysis, Transaction, TransactionStaking, TransactionStatus, TransactionTokenAssociate, SyntheticBlock, } from "../types"; export const serializeSignature = (signature: Uint8Array) => { return Buffer.from(signature).toString("base64"); }; export const deserializeSignature = (signature: string) => { return Buffer.from(signature, "base64"); }; export const serializeTransaction = (tx: HederaSDKTransaction) => { return Buffer.from(tx.toBytes()).toString("hex"); }; export const deserializeTransaction = (tx: string) => { return HederaSDKTransaction.fromBytes(Buffer.from(tx, "hex")); }; export const getOperationValue = ({ asset, operation, }: { asset: AssetInfo; operation: LiveOperation<HederaOperationExtra>; }) => { if (operation.type === "FEES") { return BigInt(0); } if (asset.type === "native" && OP_TYPES_EXCLUDING_FEES.includes(operation.type)) { return BigInt(operation.value.toFixed(0)) - BigInt(operation.fee.toFixed(0)); } return BigInt(operation.value.toFixed(0)); }; /** * Extract the transaction initiator account from a Hedera transaction_id. * * Hedera transaction IDs follow the format `0.0.ACCOUNT-TIMESTAMP-NONCE`. * The first segment (before the first `-`) is the Hedera native account ID of * the transaction initiator. * * This always returns a Hedera-native account ID (e.g. `0.0.12345`), never * an EVM address, since the Mirror Node transaction_id always uses the native format. * * In most cases, the transaction initiator is also the fee payer, but not always: see `extractFeesPayer`. */ export function extractInitiator(transactionId: string): string { return transactionId.split("-")[0]; } /** * Extract the fee payer account for a Hedera mirror transaction. * * In most cases, the transaction initiator is also the fee payer, but not always: failed * transactions can be charged to the initiator, or not. The most reliable signal for * determining the fee payer is to analyze the actual balance changes (transfers) and the * charged transaction fee. * * This helper implements a best-effort heuristic: * - it first inspects the transfers and charged fee to infer a unique fee payer when possible; * - if it cannot unambiguously identify a single payer from balance changes, it falls back to * the transaction initiator derived from the transaction id. */ export function extractFeesPayer( transaction: Pick<HederaMirrorTransaction, "transaction_id" | "transfers" | "charged_tx_fee">, ): string { const initiator = extractInitiator(transaction.transaction_id); const transfers = transaction.transfers ?? []; const chargedFee = transaction.charged_tx_fee; // no transfers or fees => use initiator if (transfers.length === 0 || chargedFee <= 0) return initiator; // if initiator has any transfer, use it if (transfers.some(transfer => transfer.account === initiator)) return initiator; // exactly one transfer with amount equal to charged fee => use that transfer's account const exactFeeTransfers = transfers.filter(transfer => transfer.amount === -chargedFee); if (exactFeeTransfers.length === 1) return exactFeeTransfers[0].account; // otherwise, fallback to initiator return initiator; } // this utils extracts the bodyBytes from a Hedera Transaction that are required for signing // hardcoded `.get(0)` is here because we are always using single node account id // this is because we want to avoid "signing" loop for users, as described here: // https://github.com/LedgerHQ/ledger-live/pull/72/commits/1e942687d4301660e43e0c4b5419fcfa2733b290 export const getHederaTransactionBodyBytes = (tx: HederaSDKTransaction) => { const bodyBytes = tx._signedTransactions.get(0)?.bodyBytes; invariant(bodyBytes, "hedera: tx body bytes are missing"); return bodyBytes; }; export const mapIntentToSDKOperation = (txIntent: TransactionIntent) => { if (txIntent.type === HEDERA_TRANSACTION_MODES.TokenAssociate) { return HEDERA_OPERATION_TYPES.TokenAssociate; } if (txIntent.type === HEDERA_TRANSACTION_MODES.Send && txIntent.asset.type === "hts") { return HEDERA_OPERATION_TYPES.TokenTransfer; } if (txIntent.type === HEDERA_TRANSACTION_MODES.Send && txIntent.asset.type === "erc20") { return HEDERA_OPERATION_TYPES.ContractCall; } if ( txIntent.type === HEDERA_TRANSACTION_MODES.Delegate || txIntent.type === HEDERA_TRANSACTION_MODES.Undelegate || txIntent.type === HEDERA_TRANSACTION_MODES.Redelegate ) { return HEDERA_OPERATION_TYPES.CryptoUpdate; } return HEDERA_OPERATION_TYPES.CryptoTransfer; }; export const getMemoFromBase64 = (memoBase64: string | undefined): string | null => { try { if (memoBase64 === undefined) return null; return Buffer.from(memoBase64, "base64").toString("utf-8"); } catch { return null; } }; // NOTE: convert from the non-url-safe version of base64 to the url-safe version (that the explorer uses) export function base64ToUrlSafeBase64(data: string): string { // Might be nice to use this alternative if .nvmrc changes to >= Node v14.18.0 // base64url encoding option isn't supported until then // Buffer.from(data, "base64").toString("base64url"); return data.replace(/\//g, "_").replace(/\+/g, "-"); } export const getTransactionExplorer = ( explorerView: ExplorerView | null | undefined, operation: LiveOperation, ): string | undefined => { const extra = isValidExtra(operation.extra) ? operation.extra : null; return explorerView?.tx?.replace( "$hash", extra?.consensusTimestamp ?? extra?.transactionId ?? "0", ); }; export const isTokenAssociateTransaction = ( tx: Transaction | null | undefined, ): tx is TransactionTokenAssociate => { return tx?.mode === HEDERA_TRANSACTION_MODES.TokenAssociate; }; export const isAutoTokenAssociationEnabled = (account: AccountLike) => { const hederaAccount = "hederaResources" in account ? (account as HederaAccount) : null; return hederaAccount?.hederaResources?.isAutoTokenAssociationEnabled ?? false; }; export const isTokenAssociationRequired = ( account: AccountLike, token: TokenCurrency | null | undefined, ) => { if (token?.tokenType !== "hts") { return false; } const subAccounts = !!account && "subAccounts" in account ? (account.subAccounts ?? []) : []; const isTokenAssociated = subAccounts.some(item => item.token.id === token?.id); return !!token && !isTokenAssociated && !isAutoTokenAssociationEnabled(account); }; export const isValidExtra = (extra: unknown): extra is HederaOperationExtra => { return !!extra && typeof extra === "object" && !Array.isArray(extra); }; export const getOperationDetailsExtraFields = ( extra: HederaOperationExtra, ): OperationDetailsExtraField[] => { const fields: OperationDetailsExtraField[] = []; if (typeof extra.memo === "string") { fields.push({ key: "memo", value: extra.memo }); } if (typeof extra.associatedTokenId === "string") { fields.push({ key: "associatedTokenId", value: extra.associatedTokenId }); } if (typeof extra.targetStakingNodeId === "number") { fields.push({ key: "targetStakingNodeId", value: extra.targetStakingNodeId.toString() }); } if (typeof extra.previousStakingNodeId === "number") { fields.push({ key: "previousStakingNodeId", value: extra.previousStakingNodeId.toString() }); } if (typeof extra.gasConsumed === "number") { fields.push({ key: "gasConsumed", value: extra.gasConsumed.toString() }); } if (typeof extra.gasUsed === "number") { fields.push({ key: "gasUsed", value: extra.gasUsed.toString() }); } if (typeof extra.gasLimit === "number") { fields.push({ key: "gasLimit", value: extra.gasLimit.toString() }); } return fields; }; // disables the "Continue" button in the Send modal's Recipient step during token transfers if: // - the recipient is not associated with the token // - the association status can't be verified export const sendRecipientCanNext = (status: TransactionStatus) => { const { missingAssociation, unverifiedAssociation } = status.warnings; return !missingAssociation && !unverifiedAssociation; }; // note: this is currently called frequently by getTransactionStatus; LRU cache prevents duplicated requests export const getCurrencyToUSDRate = makeLRUCache( async (currency: Currency) => { try { const [rate] = await cvsApi.fetchLatest([ { from: currency, to: getFiatCurrencyByTicker("USD"), startDate: new Date(), }, ]); invariant(rate, "no value returned from cvs api"); return new BigNumber(rate); } catch { return null; } }, currency => currency.ticker, seconds(3), ); export const checkAccountTokenAssociationStatus = makeLRUCache( async (address: string, token: TokenCurrency) => { if (token.tokenType !== "hts") { return true; } const [parsingError, parsingResult] = await safeParseAccountId(address); if (parsingError) { throw parsingError; } const addressWithoutChecksum = parsingResult.accountId; const mirrorAccount = await apiClient.getAccount(addressWithoutChecksum); // auto association is enabled if (mirrorAccount.max_automatic_token_associations === -1) { return true; } const isTokenAssociated = mirrorAccount.balance.tokens.some(t => { return t.token_id === token.contractAddress; }); return isTokenAssociated; }, (accountId, token) => `${accountId}-${token.contractAddress}`, seconds(30), ); export const getChecksum = (accountId: string): string | null => { try { const entityId = EntityIdHelper.fromString(accountId); return entityId.checksum ?? null; } catch { return null; } }; export const safeParseAccountId = async ( address: string, ): Promise<[Error, null] | [null, { accountId: string; checksum: string | null }]> => { const currency = findCryptoCurrencyById("hedera"); const currencyName = currency?.name ?? "Hedera"; try { const accountId = AccountId.fromString(address); const checksum = getChecksum(address); if (checksum) { const client = await rpcClient.getInstance(); const expectedChecksum = accountId.toStringWithChecksum(client).split("-")[1]; if (checksum !== expectedChecksum) { return [new HederaRecipientInvalidChecksum(), null]; } } const result = { accountId: accountId.toString(), checksum, }; return [null, result]; } catch { return [new InvalidAddress("", { currencyName }), null]; } }; export function getBlockHash(blockHeight: number): string { return createHash("sha256").update(blockHeight.toString()).digest("hex"); } /** * Calculates a synthetic block height based on Hedera consensus timestamp. * * @param consensusTimestamp - Hedera consensus timestamp * @param blockWindowSeconds - Duration of one synthetic block in seconds (default: 10) * @returns Deterministic synthetic block (height and hash) */ export function getSyntheticBlock( consensusTimestamp: string, blockWindowSeconds = SYNTHETIC_BLOCK_WINDOW_SECONDS, ): SyntheticBlock { const seconds = Math.floor(Number(consensusTimestamp)); if (Number.isNaN(seconds) || seconds === 0) { throw new Error(`Invalid consensus timestamp: ${consensusTimestamp}`); } const blockHeight = Math.floor(seconds / blockWindowSeconds); const blockHash = getBlockHash(blockHeight); const blockTime = new Date(seconds * 1000); return { blockHeight, blockHash, blockTime }; } /** * Calculates the date range based on a synthetic block height. * * @param blockHeight - The synthetic block height * @param blockWindowSeconds - Duration of one synthetic block in seconds (default: 10) * @returns block date range */ export function getDateRangeFromBlockHeight( blockHeight: number, blockWindowSeconds = SYNTHETIC_BLOCK_WINDOW_SECONDS, ) { const start = new Date(blockHeight * blockWindowSeconds * 1000); const end = new Date((blockHeight + 1) * blockWindowSeconds * 1000); return { start, end }; } export const formatTransactionId = (transactionId: TransactionId): string => { const [accountId, timestamp] = transactionId.toString().split("@"); const [secs, nanos] = timestamp.split("."); return `${accountId}-${secs}-${nanos}`; }; /** * Fetches EVM address for given Hedera account ID (e.g. "0.0.1234"). * It returns null if the fetch fails. * * @param accountId - Hedera account ID in the format `shard.realm.num` * @returns EVM address (`0x...`) or null if fetch fails */ export const toEVMAddress = async (accountId: string): Promise<string | null> => { try { const account = await apiClient.getAccount(accountId); return account.evm_address; } catch { return null; } }; /** * Converts EVM address in hexadecimal format to its corresponding Hedera account ID. * Only long-zero addresses can be mathematically converted back to account IDs. * Non-long-zero addresses support would require mirror node call and is not needed for now * Uses shard 0 and realm 0 by default for the conversion. * If the conversion fails, it returns null. * * @param evmAddress - EVM address in hexadecimal format (should start with '0x') * @param shard - Optional shard ID (defaults to 0) * @param realm - Optional realm ID (defaults to 0) * @returns Hedera account ID in the format `shard.realm.num` or null if conversion fails */ export const fromEVMAddress = (evmAddress: string, shard = 0, realm = 0): string | null => { try { const isLongZeroAddress = evmAddress.includes("0".repeat(20)); if (!isLongZeroAddress) { return null; } const accountId = AccountId.fromEvmAddress(shard, realm, evmAddress).toString(); return accountId; } catch { return null; } }; export const extractCompanyFromNodeDescription = (description: string): string => { return description .split("|")[0] .replace(/hosted by/i, "") .replace(/hosted for/i, "") .trim(); }; export const sortValidators = (validators: HederaValidator[]): HederaValidator[] => { const ledgerNodeId = getEnv("HEDERA_STAKING_LEDGER_NODE_ID"); // sort validators by active stake in DESC order, with Ledger node first if it exists return [...validators].sort((a, b) => { if (typeof ledgerNodeId === "number") { if (a.nodeId === ledgerNodeId) return -1; if (b.nodeId === ledgerNodeId) return 1; } return b.activeStake.toNumber() - a.activeStake.toNumber(); }); }; export const filterValidatorBySearchTerm = ( validator: HederaValidator, search: string, ): boolean => { const lowercaseSearch = search.toLowerCase(); const addressWithChecksum = validator.addressChecksum ? `${validator.address}-${validator.addressChecksum}` : validator.address; return ( validator.nodeId.toString().includes(lowercaseSearch) || validator.name.toLowerCase().includes(lowercaseSearch) || addressWithChecksum.toLowerCase().includes(lowercaseSearch) ); }; export const getValidatorFromAccount = (account: HederaAccount): HederaValidator | null => { const { delegation } = account.hederaResources ?? {}; if (!delegation) { return null; } const validators = getCurrentHederaPreloadData(account.currency); const validator = validators.validators.find(v => v.nodeId === delegation.nodeId) ?? null; return validator; }; export const getDefaultValidator = (validators: HederaValidator[]): HederaValidator | null => { const ledgerNodeId = getEnv("HEDERA_STAKING_LEDGER_NODE_ID"); return validators.find(v => v.nodeId === ledgerNodeId) ?? null; }; export const getDelegationStatus = ( validator: HederaValidator | null, ): HEDERA_DELEGATION_STATUS => { if (!validator?.address) { return HEDERA_DELEGATION_STATUS.Inactive; } if (validator.overstaked) { return HEDERA_DELEGATION_STATUS.Overstaked; } return HEDERA_DELEGATION_STATUS.Active; }; export const isStakingTransaction = ( tx: Transaction | null | undefined, ): tx is TransactionStaking => { if (!tx) return false; return ( tx.mode === HEDERA_TRANSACTION_MODES.Delegate || tx.mode === HEDERA_TRANSACTION_MODES.Undelegate || tx.mode === HEDERA_TRANSACTION_MODES.Redelegate || tx.mode === HEDERA_TRANSACTION_MODES.ClaimRewards ); }; export const hasSpecificIntentData = <Type extends "staking" | "erc20">( txIntent: TransactionIntent<HederaMemo, HederaTxData>, expectedType: Type, ): txIntent is Extract<TransactionIntent<HederaMemo, HederaTxData>, { data: { type: Type } }> => { return "data" in txIntent && txIntent.data.type === expectedType; }; export const calculateAPY = (rewardRateStart: number): number => { const dailyRate = rewardRateStart / 10 ** TINYBAR_SCALE; const annualRate = dailyRate * 365; return annualRate; }; /** * Calculates the uncommitted balance change for an account between two timestamps. * * This function handles the timing mismatch between Mirror Node balance snapshots and actual transactions. * Balance snapshots are taken at regular intervals, not at every transaction, so querying by exact timestamp * may return a snapshot from before moment you need. * * @param address - Hedera account ID (e.g., "0.0.12345") * @param startTimestamp - Start of the time range (exclusive, format: "1234567890.123456789") * @param endTimestamp - End of the time range (inclusive, format: "1234567890.123456789") * @returns The net balance change as BigInt (sum of all transfers to/from the account) */ export const calculateUncommittedBalanceChange = async ({ address, startTimestamp, endTimestamp, }: { address: string; startTimestamp: string; endTimestamp: string; }): Promise<BigNumber> => { if (Number(startTimestamp) >= Number(endTimestamp)) { return new BigNumber(0); } const uncommittedTransactions = await apiClient.getTransactionsByTimestampRange({ address, startTimestamp: `gt:${startTimestamp}`, endTimestamp: `lte:${endTimestamp}`, }); // Sum all balance changes from transfers related to this account const uncommittedBalanceChange = uncommittedTransactions.reduce((total, tx) => { const transfers = tx.transfers ?? []; const relevantTransfers = transfers.filter(t => t.account === address); const netChange = relevantTransfers.reduce((sum, t) => sum.plus(t.amount), new BigNumber(0)); return total.plus(netChange); }, new BigNumber(0)); return uncommittedBalanceChange; }; /** * Hedera uses the AccountUpdateTransaction for multiple purposes, including staking operations. * Mirror node classifies all such transactions under the same name: "CRYPTOUPDATEACCOUNT". * * This function distinguishes between: * - DELEGATE: Account started staking (staked_node_id changed from null to a node ID) * - UNDELEGATE: Account stopped staking (staked_node_id changed from a node ID to null) * - REDELEGATE: Account changed staking node (staked_node_id changed from one node to another) * * The analysis works by: * 1. Fetching the account state BEFORE the transaction (using lt: timestamp filter) * 2. Fetching the account state AFTER the transaction (using eq: timestamp filter) * 3. Comparing the staked_node_id field to determine what changed * 4. Calculating the actual staked amount by replaying uncommitted transactions between * the latest balance snapshot and the staking operation to handle snapshot timing mismatches * * @performance * Makes 3 API calls per operation: * - account state before * - account state after * - transaction history based on latest balance snapshot * * Batching would complicate code for minimal gain given low staking op frequency. */ export const analyzeStakingOperation = async ( address: string, mirrorTx: HederaMirrorTransaction, ): Promise<StakingAnalysis | null> => { const [accountBefore, accountAfter] = await Promise.all([ apiClient.getAccount(address, `lt:${mirrorTx.consensus_timestamp}`), apiClient.getAccount(address, `eq:${mirrorTx.consensus_timestamp}`), ]); let operationType: OperationType | null = null; const previousStakingNodeId = accountBefore.staked_node_id; const targetStakingNodeId = accountAfter.staked_node_id; // stake: node id changed from null -> not null if (previousStakingNodeId === null && targetStakingNodeId !== null) { operationType = "DELEGATE"; } // unstake: node id changed from not null -> null else if (previousStakingNodeId !== null && targetStakingNodeId === null) { operationType = "UNDELEGATE"; } // restake: node id changed from not null -> different not null else if ( previousStakingNodeId !== null && targetStakingNodeId !== null && previousStakingNodeId !== targetStakingNodeId ) { operationType = "REDELEGATE"; } if (!operationType) { return null; } // calculate uncommitted balance changes between the last snapshot and the staking tx const uncommittedBalanceChange = await calculateUncommittedBalanceChange({ address, startTimestamp: accountAfter.balance.timestamp, endTimestamp: mirrorTx.consensus_timestamp, }); const actualStakedAmount = uncommittedBalanceChange.plus(accountAfter.balance.balance); return { operationType, previousStakingNodeId, targetStakingNodeId, stakedAmount: BigInt(actualStakedAmount.toString()), // always entire balance on Hedera (fully liquid) }; }; export const toEntityId = ({ num, shard = 0, realm = 0, }: { num: number; shard?: number; realm?: number; }): string => { invariant(Number.isInteger(shard) && shard >= 0, `invalid account shard: ${shard}`); invariant(Number.isInteger(realm) && realm >= 0, `invalid account realm: ${realm}`); invariant(Number.isInteger(num) && num >= 0, `invalid account num: ${num}`); return `${shard}.${realm}.${num}`; }; /** * Merges transactions from Mirror Node and Hgraph ERC20 transfers into a unified, sorted list. * * This function handles the complexity of combining two data sources that may have different indexing speeds: * - Mirror Node transactions (native HBAR transfers, HTS tokens, contract calls, etc.) * - Hgraph ERC20 transfers (indexed separately, may lag behind Mirror Node) * * Key behaviors: * 1. Merges both sources into a single array with type discrimination * 2. Sorts by consensus timestamp (nanosecond precision) in specified order * 3. Detects and handles ERC20 indexing delays by filtering out transactions after Hgraph's latest timestamp * 4. Applies pagination limit and generates next cursor for pagination * * @param mirrorTransactions - Transactions from Mirror Node API * @param enrichedERC20Transfers - enriched ERC20 transfers from Hgraph API * @param order - Sort order: "asc" or "desc" * @param limit - Maximum number of transactions to return (only applied when fetchAllPages is false) * @param latestHgraphIndexedTimestampNs - Latest consensus timestamp indexed by Hgraph (used for delay detection) * @param fetchAllPages - If true, returns all transactions; if false, applies limit * @returns Object containing merged transactions and next cursor that can be used for pagination */ export const mergeTransactionsFromDifferentSources = ({ mirrorTransactions, enrichedERC20Transfers, order, limit, latestHgraphIndexedTimestampNs, fetchAllPages, }: { mirrorTransactions: HederaMirrorTransaction[]; enrichedERC20Transfers: EnrichedERC20Transfer[]; order: "asc" | "desc"; limit: number; latestHgraphIndexedTimestampNs: BigNumber; fetchAllPages: boolean; }) => { let merged: MergedTransaction[] = []; let nextCursor: string | null = null; const latestHgraphIndexedTimestampSeconds = nanosToSeconds(latestHgraphIndexedTimestampNs); // merge both transaction sources merged.push( ...mirrorTransactions.map(tx => ({ type: "mirror" as const, data: tx })), ...enrichedERC20Transfers.map(tx => ({ type: "erc20" as const, data: tx })), ); // lookup map of ERC20 transfers by mirror transaction hash for deduplication purposes const erc20TransferByMirrorHash = new Map( merged .filter((item): item is Extract<MergedTransaction, { type: "erc20" }> => { return item.type === "erc20"; }) .map(item => [item.data.mirrorTransaction.transaction_hash, item.data]), ); // filter out mirror transactions based on ERC20 transfer data to avoid duplicates merged = merged.filter(item => { if (item.type !== "mirror") return true; const isChildTransaction = item.data.parent_consensus_timestamp !== null; const hasErc20Transfer = erc20TransferByMirrorHash.has(item.data.transaction_hash); // ignore child transactions of CONTRACT_CALL if details from erc20 data source are available if (isChildTransaction && hasErc20Transfer) return false; // deduplicate CONTRACT_CALL transactions return !hasErc20Transfer; }); // sort merged transactions by consensus timestamp, keeping nanoseconds precision merged.sort((a, b) => { const aMirrorTx = a.type === "mirror" ? a.data : a.data.mirrorTransaction; const bMirrorTx = b.type === "mirror" ? b.data : b.data.mirrorTransaction; const aTime = new BigNumber(aMirrorTx.consensus_timestamp); const bTime = new BigNumber(bMirrorTx.consensus_timestamp); const result = order === "desc" ? bTime.minus(aTime) : aTime.minus(bTime); return result.toNumber(); }); // if mirror node returned CONTRACT_CALL with timestamp higher than latest hgraph indexed timestamp, // we need to remove all transactions that happened after that timestamp to avoid duplicates in next sync const isERC20Delayed = merged.some( i => i.type === "mirror" && i.data.name === HEDERA_TRANSACTION_NAMES.ContractCall && new BigNumber(i.data.consensus_timestamp).gt(latestHgraphIndexedTimestampSeconds), ); if (isERC20Delayed) { log( "hedera/sync", `detected ERC20 indexing delay, filtering out transactions after ${latestHgraphIndexedTimestampSeconds.toString()}`, ); merged = merged.filter(i => { const mirrorTx = i.type === "mirror" ? i.data : i.data.mirrorTransaction; return new BigNumber(mirrorTx.consensus_timestamp).lte(latestHgraphIndexedTimestampSeconds); }); } // apply final limit in pagination mode if (!fetchAllPages && merged.length >= limit) { merged = merged.slice(0, limit); } // for pagination mode, set nextCursor as the timestamp of the latest transaction // desc: using oldest (last) timestamp to fetch older operations next // asc: using newest (last) timestamp to fetch newer operations next const last = merged.at(-1); if (last) { const lastMirrorTx = last.type === "mirror" ? last.data : last.data.mirrorTransaction; nextCursor = lastMirrorTx.consensus_timestamp; } return { merged, nextCursor }; }; export function millisToSeconds(millis: number | BigNumber): BigNumber { return new BigNumber(millis).dividedBy(10 ** 3); } export function secondsToNanos(seconds: number | BigNumber): BigNumber { return new BigNumber(seconds).multipliedBy(10 ** 9); } export function nanosToSeconds(nanos: number | BigNumber): BigNumber { return new BigNumber(nanos).dividedBy(10 ** 9); } export function toTimestamp(consensusTimestamp: string): Timestamp { const [secondsPart, nanosPart] = consensusTimestamp.split("."); invariant( typeof secondsPart === "string" && typeof nanosPart === "string", `invalid consensus timestamp format: ${consensusTimestamp}`, ); return new Timestamp(Number(secondsPart), Number(nanosPart.padEnd(9, "0"))); } export function createStakingRewardOperationHash(hash: string): string { return `${hash}${STAKING_REWARD_HASH_SUFFIX}`; }