@ledgerhq/coin-hedera
Version:
Ledger Hedera Coin integration
639 lines • 31.2 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.mergeTransactionsFromDifferentSources = exports.toEntityId = exports.analyzeStakingOperation = exports.calculateUncommittedBalanceChange = exports.calculateAPY = exports.hasSpecificIntentData = exports.isStakingTransaction = exports.getDelegationStatus = exports.getDefaultValidator = exports.getValidatorFromAccount = exports.filterValidatorBySearchTerm = exports.sortValidators = exports.extractCompanyFromNodeDescription = exports.fromEVMAddress = exports.toEVMAddress = exports.formatTransactionId = exports.safeParseAccountId = exports.getChecksum = exports.checkAccountTokenAssociationStatus = exports.getCurrencyToUSDRate = exports.sendRecipientCanNext = exports.getOperationDetailsExtraFields = exports.isValidExtra = exports.isTokenAssociationRequired = exports.isAutoTokenAssociationEnabled = exports.isTokenAssociateTransaction = exports.getTransactionExplorer = exports.getMemoFromBase64 = exports.mapIntentToSDKOperation = exports.getHederaTransactionBodyBytes = exports.getOperationValue = exports.deserializeTransaction = exports.serializeTransaction = exports.deserializeSignature = exports.serializeSignature = void 0;
exports.extractInitiator = extractInitiator;
exports.extractFeesPayer = extractFeesPayer;
exports.base64ToUrlSafeBase64 = base64ToUrlSafeBase64;
exports.getBlockHash = getBlockHash;
exports.getSyntheticBlock = getSyntheticBlock;
exports.getDateRangeFromBlockHeight = getDateRangeFromBlockHeight;
exports.millisToSeconds = millisToSeconds;
exports.secondsToNanos = secondsToNanos;
exports.nanosToSeconds = nanosToSeconds;
exports.toTimestamp = toTimestamp;
exports.createStakingRewardOperationHash = createStakingRewardOperationHash;
const crypto_1 = require("crypto");
const sdk_1 = require("@hashgraph/sdk");
const currencies_1 = require("@ledgerhq/cryptoassets/currencies");
const fiats_1 = require("@ledgerhq/cryptoassets/fiats");
const errors_1 = require("@ledgerhq/errors");
const index_1 = __importDefault(require("@ledgerhq/live-countervalues/api/index"));
const live_env_1 = require("@ledgerhq/live-env");
const cache_1 = require("@ledgerhq/live-network/cache");
const logs_1 = require("@ledgerhq/logs");
const bignumber_js_1 = __importDefault(require("bignumber.js"));
const invariant_1 = __importDefault(require("invariant"));
const constants_1 = require("../constants");
const errors_2 = require("../errors");
const api_1 = require("../network/api");
const rpc_1 = require("../network/rpc");
const preload_data_1 = require("../preload-data");
const serializeSignature = (signature) => {
return Buffer.from(signature).toString("base64");
};
exports.serializeSignature = serializeSignature;
const deserializeSignature = (signature) => {
return Buffer.from(signature, "base64");
};
exports.deserializeSignature = deserializeSignature;
const serializeTransaction = (tx) => {
return Buffer.from(tx.toBytes()).toString("hex");
};
exports.serializeTransaction = serializeTransaction;
const deserializeTransaction = (tx) => {
return sdk_1.Transaction.fromBytes(Buffer.from(tx, "hex"));
};
exports.deserializeTransaction = deserializeTransaction;
const getOperationValue = ({ asset, operation, }) => {
if (operation.type === "FEES") {
return BigInt(0);
}
if (asset.type === "native" && constants_1.OP_TYPES_EXCLUDING_FEES.includes(operation.type)) {
return BigInt(operation.value.toFixed(0)) - BigInt(operation.fee.toFixed(0));
}
return BigInt(operation.value.toFixed(0));
};
exports.getOperationValue = getOperationValue;
/**
* 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`.
*/
function extractInitiator(transactionId) {
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.
*/
function extractFeesPayer(transaction) {
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
const getHederaTransactionBodyBytes = (tx) => {
const bodyBytes = tx._signedTransactions.get(0)?.bodyBytes;
(0, invariant_1.default)(bodyBytes, "hedera: tx body bytes are missing");
return bodyBytes;
};
exports.getHederaTransactionBodyBytes = getHederaTransactionBodyBytes;
const mapIntentToSDKOperation = (txIntent) => {
if (txIntent.type === constants_1.HEDERA_TRANSACTION_MODES.TokenAssociate) {
return constants_1.HEDERA_OPERATION_TYPES.TokenAssociate;
}
if (txIntent.type === constants_1.HEDERA_TRANSACTION_MODES.Send && txIntent.asset.type === "hts") {
return constants_1.HEDERA_OPERATION_TYPES.TokenTransfer;
}
if (txIntent.type === constants_1.HEDERA_TRANSACTION_MODES.Send && txIntent.asset.type === "erc20") {
return constants_1.HEDERA_OPERATION_TYPES.ContractCall;
}
if (txIntent.type === constants_1.HEDERA_TRANSACTION_MODES.Delegate ||
txIntent.type === constants_1.HEDERA_TRANSACTION_MODES.Undelegate ||
txIntent.type === constants_1.HEDERA_TRANSACTION_MODES.Redelegate) {
return constants_1.HEDERA_OPERATION_TYPES.CryptoUpdate;
}
return constants_1.HEDERA_OPERATION_TYPES.CryptoTransfer;
};
exports.mapIntentToSDKOperation = mapIntentToSDKOperation;
const getMemoFromBase64 = (memoBase64) => {
try {
if (memoBase64 === undefined)
return null;
return Buffer.from(memoBase64, "base64").toString("utf-8");
}
catch {
return null;
}
};
exports.getMemoFromBase64 = getMemoFromBase64;
// NOTE: convert from the non-url-safe version of base64 to the url-safe version (that the explorer uses)
function base64ToUrlSafeBase64(data) {
// 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, "-");
}
const getTransactionExplorer = (explorerView, operation) => {
const extra = (0, exports.isValidExtra)(operation.extra) ? operation.extra : null;
return explorerView?.tx?.replace("$hash", extra?.consensusTimestamp ?? extra?.transactionId ?? "0");
};
exports.getTransactionExplorer = getTransactionExplorer;
const isTokenAssociateTransaction = (tx) => {
return tx?.mode === constants_1.HEDERA_TRANSACTION_MODES.TokenAssociate;
};
exports.isTokenAssociateTransaction = isTokenAssociateTransaction;
const isAutoTokenAssociationEnabled = (account) => {
const hederaAccount = "hederaResources" in account ? account : null;
return hederaAccount?.hederaResources?.isAutoTokenAssociationEnabled ?? false;
};
exports.isAutoTokenAssociationEnabled = isAutoTokenAssociationEnabled;
const isTokenAssociationRequired = (account, token) => {
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 && !(0, exports.isAutoTokenAssociationEnabled)(account);
};
exports.isTokenAssociationRequired = isTokenAssociationRequired;
const isValidExtra = (extra) => {
return !!extra && typeof extra === "object" && !Array.isArray(extra);
};
exports.isValidExtra = isValidExtra;
const getOperationDetailsExtraFields = (extra) => {
const fields = [];
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;
};
exports.getOperationDetailsExtraFields = getOperationDetailsExtraFields;
// 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
const sendRecipientCanNext = (status) => {
const { missingAssociation, unverifiedAssociation } = status.warnings;
return !missingAssociation && !unverifiedAssociation;
};
exports.sendRecipientCanNext = sendRecipientCanNext;
// note: this is currently called frequently by getTransactionStatus; LRU cache prevents duplicated requests
exports.getCurrencyToUSDRate = (0, cache_1.makeLRUCache)(async (currency) => {
try {
const [rate] = await index_1.default.fetchLatest([
{
from: currency,
to: (0, fiats_1.getFiatCurrencyByTicker)("USD"),
startDate: new Date(),
},
]);
(0, invariant_1.default)(rate, "no value returned from cvs api");
return new bignumber_js_1.default(rate);
}
catch {
return null;
}
}, currency => currency.ticker, (0, cache_1.seconds)(3));
exports.checkAccountTokenAssociationStatus = (0, cache_1.makeLRUCache)(async (address, token) => {
if (token.tokenType !== "hts") {
return true;
}
const [parsingError, parsingResult] = await (0, exports.safeParseAccountId)(address);
if (parsingError) {
throw parsingError;
}
const addressWithoutChecksum = parsingResult.accountId;
const mirrorAccount = await api_1.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}`, (0, cache_1.seconds)(30));
const getChecksum = (accountId) => {
try {
const entityId = sdk_1.EntityIdHelper.fromString(accountId);
return entityId.checksum ?? null;
}
catch {
return null;
}
};
exports.getChecksum = getChecksum;
const safeParseAccountId = async (address) => {
const currency = (0, currencies_1.findCryptoCurrencyById)("hedera");
const currencyName = currency?.name ?? "Hedera";
try {
const accountId = sdk_1.AccountId.fromString(address);
const checksum = (0, exports.getChecksum)(address);
if (checksum) {
const client = await rpc_1.rpcClient.getInstance();
const expectedChecksum = accountId.toStringWithChecksum(client).split("-")[1];
if (checksum !== expectedChecksum) {
return [new errors_2.HederaRecipientInvalidChecksum(), null];
}
}
const result = {
accountId: accountId.toString(),
checksum,
};
return [null, result];
}
catch {
return [new errors_1.InvalidAddress("", { currencyName }), null];
}
};
exports.safeParseAccountId = safeParseAccountId;
function getBlockHash(blockHeight) {
return (0, crypto_1.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)
*/
function getSyntheticBlock(consensusTimestamp, blockWindowSeconds = constants_1.SYNTHETIC_BLOCK_WINDOW_SECONDS) {
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
*/
function getDateRangeFromBlockHeight(blockHeight, blockWindowSeconds = constants_1.SYNTHETIC_BLOCK_WINDOW_SECONDS) {
const start = new Date(blockHeight * blockWindowSeconds * 1000);
const end = new Date((blockHeight + 1) * blockWindowSeconds * 1000);
return { start, end };
}
const formatTransactionId = (transactionId) => {
const [accountId, timestamp] = transactionId.toString().split("@");
const [secs, nanos] = timestamp.split(".");
return `${accountId}-${secs}-${nanos}`;
};
exports.formatTransactionId = formatTransactionId;
/**
* 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
*/
const toEVMAddress = async (accountId) => {
try {
const account = await api_1.apiClient.getAccount(accountId);
return account.evm_address;
}
catch {
return null;
}
};
exports.toEVMAddress = toEVMAddress;
/**
* 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
*/
const fromEVMAddress = (evmAddress, shard = 0, realm = 0) => {
try {
const isLongZeroAddress = evmAddress.includes("0".repeat(20));
if (!isLongZeroAddress) {
return null;
}
const accountId = sdk_1.AccountId.fromEvmAddress(shard, realm, evmAddress).toString();
return accountId;
}
catch {
return null;
}
};
exports.fromEVMAddress = fromEVMAddress;
const extractCompanyFromNodeDescription = (description) => {
return description
.split("|")[0]
.replace(/hosted by/i, "")
.replace(/hosted for/i, "")
.trim();
};
exports.extractCompanyFromNodeDescription = extractCompanyFromNodeDescription;
const sortValidators = (validators) => {
const ledgerNodeId = (0, live_env_1.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();
});
};
exports.sortValidators = sortValidators;
const filterValidatorBySearchTerm = (validator, search) => {
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));
};
exports.filterValidatorBySearchTerm = filterValidatorBySearchTerm;
const getValidatorFromAccount = (account) => {
const { delegation } = account.hederaResources ?? {};
if (!delegation) {
return null;
}
const validators = (0, preload_data_1.getCurrentHederaPreloadData)(account.currency);
const validator = validators.validators.find(v => v.nodeId === delegation.nodeId) ?? null;
return validator;
};
exports.getValidatorFromAccount = getValidatorFromAccount;
const getDefaultValidator = (validators) => {
const ledgerNodeId = (0, live_env_1.getEnv)("HEDERA_STAKING_LEDGER_NODE_ID");
return validators.find(v => v.nodeId === ledgerNodeId) ?? null;
};
exports.getDefaultValidator = getDefaultValidator;
const getDelegationStatus = (validator) => {
if (!validator?.address) {
return constants_1.HEDERA_DELEGATION_STATUS.Inactive;
}
if (validator.overstaked) {
return constants_1.HEDERA_DELEGATION_STATUS.Overstaked;
}
return constants_1.HEDERA_DELEGATION_STATUS.Active;
};
exports.getDelegationStatus = getDelegationStatus;
const isStakingTransaction = (tx) => {
if (!tx)
return false;
return (tx.mode === constants_1.HEDERA_TRANSACTION_MODES.Delegate ||
tx.mode === constants_1.HEDERA_TRANSACTION_MODES.Undelegate ||
tx.mode === constants_1.HEDERA_TRANSACTION_MODES.Redelegate ||
tx.mode === constants_1.HEDERA_TRANSACTION_MODES.ClaimRewards);
};
exports.isStakingTransaction = isStakingTransaction;
const hasSpecificIntentData = (txIntent, expectedType) => {
return "data" in txIntent && txIntent.data.type === expectedType;
};
exports.hasSpecificIntentData = hasSpecificIntentData;
const calculateAPY = (rewardRateStart) => {
const dailyRate = rewardRateStart / 10 ** constants_1.TINYBAR_SCALE;
const annualRate = dailyRate * 365;
return annualRate;
};
exports.calculateAPY = calculateAPY;
/**
* 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)
*/
const calculateUncommittedBalanceChange = async ({ address, startTimestamp, endTimestamp, }) => {
if (Number(startTimestamp) >= Number(endTimestamp)) {
return new bignumber_js_1.default(0);
}
const uncommittedTransactions = await api_1.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_js_1.default(0));
return total.plus(netChange);
}, new bignumber_js_1.default(0));
return uncommittedBalanceChange;
};
exports.calculateUncommittedBalanceChange = calculateUncommittedBalanceChange;
/**
* 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.
*/
const analyzeStakingOperation = async (address, mirrorTx) => {
const [accountBefore, accountAfter] = await Promise.all([
api_1.apiClient.getAccount(address, `lt:${mirrorTx.consensus_timestamp}`),
api_1.apiClient.getAccount(address, `eq:${mirrorTx.consensus_timestamp}`),
]);
let operationType = 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 (0, exports.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)
};
};
exports.analyzeStakingOperation = analyzeStakingOperation;
const toEntityId = ({ num, shard = 0, realm = 0, }) => {
(0, invariant_1.default)(Number.isInteger(shard) && shard >= 0, `invalid account shard: ${shard}`);
(0, invariant_1.default)(Number.isInteger(realm) && realm >= 0, `invalid account realm: ${realm}`);
(0, invariant_1.default)(Number.isInteger(num) && num >= 0, `invalid account num: ${num}`);
return `${shard}.${realm}.${num}`;
};
exports.toEntityId = toEntityId;
/**
* 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
*/
const mergeTransactionsFromDifferentSources = ({ mirrorTransactions, enrichedERC20Transfers, order, limit, latestHgraphIndexedTimestampNs, fetchAllPages, }) => {
let merged = [];
let nextCursor = null;
const latestHgraphIndexedTimestampSeconds = nanosToSeconds(latestHgraphIndexedTimestampNs);
// merge both transaction sources
merged.push(...mirrorTransactions.map(tx => ({ type: "mirror", data: tx })), ...enrichedERC20Transfers.map(tx => ({ type: "erc20", data: tx })));
// lookup map of ERC20 transfers by mirror transaction hash for deduplication purposes
const erc20TransferByMirrorHash = new Map(merged
.filter((item) => {
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_js_1.default(aMirrorTx.consensus_timestamp);
const bTime = new bignumber_js_1.default(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 === constants_1.HEDERA_TRANSACTION_NAMES.ContractCall &&
new bignumber_js_1.default(i.data.consensus_timestamp).gt(latestHgraphIndexedTimestampSeconds));
if (isERC20Delayed) {
(0, logs_1.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_js_1.default(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 };
};
exports.mergeTransactionsFromDifferentSources = mergeTransactionsFromDifferentSources;
function millisToSeconds(millis) {
return new bignumber_js_1.default(millis).dividedBy(10 ** 3);
}
function secondsToNanos(seconds) {
return new bignumber_js_1.default(seconds).multipliedBy(10 ** 9);
}
function nanosToSeconds(nanos) {
return new bignumber_js_1.default(nanos).dividedBy(10 ** 9);
}
function toTimestamp(consensusTimestamp) {
const [secondsPart, nanosPart] = consensusTimestamp.split(".");
(0, invariant_1.default)(typeof secondsPart === "string" && typeof nanosPart === "string", `invalid consensus timestamp format: ${consensusTimestamp}`);
return new sdk_1.Timestamp(Number(secondsPart), Number(nanosPart.padEnd(9, "0")));
}
function createStakingRewardOperationHash(hash) {
return `${hash}${constants_1.STAKING_REWARD_HASH_SUFFIX}`;
}
//# sourceMappingURL=utils.js.map