UNPKG

@ledgerhq/coin-hedera

Version:
346 lines 15.1 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.analyzeStakingOperation = exports.calculateUncommittedBalanceChange = exports.toEVMAddress = exports.safeParseAccountId = exports.checkAccountTokenAssociationStatus = exports.getCurrencyToUSDRate = exports.enrichERC20Transfers = void 0; exports.createTransactionId = createTransactionId; exports.parseTransfers = parseTransfers; exports.getERC20BalancesForAccountV2 = getERC20BalancesForAccountV2; const invariant_1 = __importDefault(require("invariant")); const sdk_1 = require("@hashgraph/sdk"); const currencies_1 = require("@ledgerhq/cryptoassets/currencies"); const fiats_1 = require("@ledgerhq/cryptoassets/fiats"); const state_1 = require("@ledgerhq/cryptoassets/state"); 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 bignumber_js_1 = __importDefault(require("bignumber.js")); const constants_1 = require("../constants"); const errors_2 = require("../errors"); const utils_1 = require("../logic/utils"); const api_1 = require("./api"); const hgraph_1 = require("./hgraph"); const rpc_1 = require("./rpc"); async function createTransactionId(accountId, config) { if (!config.useNetworkTimestamp) { return sdk_1.TransactionId.generate(accountId); } try { const lastBlock = await api_1.apiClient.getLatestBlock({ configOrCurrencyId: config }); const validStart = (0, utils_1.toTimestamp)(lastBlock.timestamp.to ?? lastBlock.timestamp.from); return sdk_1.TransactionId.withValidStart(sdk_1.AccountId.fromString(accountId), validStart); } catch { return sdk_1.TransactionId.generate(accountId); } } function isValidRecipient(accountId, recipients) { if (accountId.shard.eq(0) && accountId.realm.eq(0)) { // account is a node, only add to list if we have none if (accountId.num.lt(100)) { return recipients.length === 0; } // account is a system account that is not a node, do NOT add if (accountId.num.lt(1000)) { return false; } } return true; } function parseTransfers(mirrorTransfers, address, stakingReward = new bignumber_js_1.default(0)) { let value = new bignumber_js_1.default(0); let type = "NONE"; const senders = []; const recipients = []; const rewardPayerAddress = (0, live_env_1.getEnv)("HEDERA_STAKING_REWARD_ACCOUNT_ID"); for (const transfer of mirrorTransfers) { const amount = new bignumber_js_1.default(transfer.amount); const accountId = sdk_1.AccountId.fromString(transfer.account); // staking reward is included in transfer, so it can be positive even if user sent less HBARs than the reward is const amountWithoutReward = transfer.account === address ? amount.minus(stakingReward) : amount; if (transfer.account === address) { value = amountWithoutReward.abs(); type = amountWithoutReward.isNegative() ? "OUT" : "IN"; } if (amountWithoutReward.isNegative()) { // exclude reward payer from senders list, because rewards are shown as separate operations const shouldIgnoreAddress = transfer.account === rewardPayerAddress && stakingReward.gt(0); if (shouldIgnoreAddress) { continue; } senders.push(transfer.account); } else if (isValidRecipient(accountId, recipients)) { recipients.push(transfer.account); } } // NOTE: earlier addresses are the "fee" addresses senders.reverse(); recipients.reverse(); return { type, value, senders, recipients, }; } async function getERC20BalancesForAccountV2({ configOrCurrencyId, address, }) { const balances = []; const rawBalances = await hgraph_1.hgraphClient.getERC20Balances({ configOrCurrencyId, address }); for (const rawBalance of rawBalances) { const rawBalanceTokenId = (0, utils_1.toEntityId)({ num: rawBalance.token_id }); const supportedToken = constants_1.SUPPORTED_ERC20_TOKENS.find(token => { return token.tokenId === rawBalanceTokenId; }); if (!supportedToken) { continue; } const calToken = await (0, state_1.getCryptoAssetsStore)().findTokenById(supportedToken.id); if (!calToken) { continue; } balances.push({ token: calToken, balance: new bignumber_js_1.default(rawBalance.balance), }); } return balances; } /** * Enriches raw ERC20 transfers from Hgraph with additional data needed for operations: * - fetches contract call result containing gas metrics and block hash * - finds the corresponding Mirror Node transaction by consensus timestamp * * @param erc20Transfers - Raw ERC20 transfers from Hgraph API * @returns Array of enriched transfers with complete operation data, filtered to supported tokens only */ const enrichERC20Transfers = async ({ configOrCurrencyId, erc20Transfers, }) => { const enrichedTransfers = []; // with hgraph we can get two different transfers with the same transaction hash const groupedByTxHash = new Map(); for (const transfer of erc20Transfers) { const group = groupedByTxHash.get(transfer.transaction_hash); if (!group) { groupedByTxHash.set(transfer.transaction_hash, [transfer]); continue; } group.push(transfer); } for (const [txHash, transfers] of groupedByTxHash.entries()) { const payerAddress = (0, utils_1.toEntityId)({ num: transfers[0].payer_account_id }); const inaccurateConsensusTimestampNs = new bignumber_js_1.default(transfers[0].consensus_timestamp); const inaccurateConsensusTimestamp = (0, utils_1.nanosToSeconds)(inaccurateConsensusTimestampNs).toFixed(9); const [contractCallResult, mirrorTransaction] = await Promise.all([ api_1.apiClient.getContractCallResult({ configOrCurrencyId, transactionHash: txHash }), api_1.apiClient.findTransactionByContractCallV2({ configOrCurrencyId, payerAddress, timestamp: inaccurateConsensusTimestamp, }), ]); if (!mirrorTransaction) { continue; } enrichedTransfers.push({ transfers, contractCallResult, mirrorTransaction, }); } return enrichedTransfers; }; exports.enrichERC20Transfers = enrichERC20Transfers; // 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)({ configOrCurrencyId: token.parentCurrencyId, address, }); if (parsingError) { throw parsingError; } const addressWithoutChecksum = parsingResult.accountId; const mirrorAccount = await api_1.apiClient.getAccount({ configOrCurrencyId: token.parentCurrencyId, address: 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 safeParseAccountId = async ({ configOrCurrencyId, address, }) => { const currency = (0, currencies_1.getCryptoCurrencyById)("hedera"); const currencyName = currency?.name ?? "Hedera"; try { const accountId = sdk_1.AccountId.fromString(address); const checksum = (0, utils_1.getChecksum)(address); if (checksum) { const client = await rpc_1.rpcClient.getInstance(configOrCurrencyId); 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; /** * 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 ({ configOrCurrencyId, accountId, }) => { try { const account = await api_1.apiClient.getAccount({ configOrCurrencyId, address: accountId, }); return account.evm_address; } catch { return null; } }; exports.toEVMAddress = toEVMAddress; /** * 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 ({ configOrCurrencyId, address, startTimestamp, endTimestamp, }) => { if (Number(startTimestamp) >= Number(endTimestamp)) { return new bignumber_js_1.default(0); } const uncommittedTransactions = await api_1.apiClient.getTransactionsByTimestampRange({ configOrCurrencyId, 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 ({ configOrCurrencyId, address, mirrorTx, }) => { const [accountBefore, accountAfter] = await Promise.all([ api_1.apiClient.getAccount({ configOrCurrencyId, address, timestamp: `lt:${mirrorTx.consensus_timestamp}`, }), api_1.apiClient.getAccount({ configOrCurrencyId, address, timestamp: `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)({ configOrCurrencyId, 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; //# sourceMappingURL=utils.js.map